Files
app/src/__tests__/ai.test.ts
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00

469 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config as appConfig } from "../config/env";
import { securityHeaders } from "../middleware/security";
import aiRoutes from "../routes/admin/ai";
import {
computeCostUsd,
recordUsage,
getMonthSpendUsd,
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service";
const KIND = "test_ai"; // marker so we only clean our own rows
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const CONV_UID_B = 999999992;
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
beforeEach(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
});
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
await prisma.ai_conversations.deleteMany({
where: { title: { contains: HIST_MARKER } },
});
});
describe("ai.service cost + budget", () => {
it("computes Sonnet 4.6 cost from tokens", () => {
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
expect(
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
).toBeCloseTo(18, 6);
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
0.0195,
6,
);
});
it("records usage with the computed cost", async () => {
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 4000,
outputTokens: 500,
});
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
expect(rows.length).toBe(1);
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
});
it("sums only the current month's spend (excludes prior months)", async () => {
// Measure the baseline so the assertion is robust to any OTHER current-month
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
// upper bound.
const before = await getMonthSpendUsd();
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 1_000_000,
outputTokens: 0,
}); // +$3 this month
// An old row (year 2000) must NOT count toward the current month.
await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
output_tokens: 0,
cost_usd: 3,
created_at: new Date("2000-01-01T00:00:00Z"),
},
});
const after = await getMonthSpendUsd();
// Only the $3 current-month row moved the needle; the year-2000 $3 is
// excluded. The delta is exactly $3 (not $6).
expect(after - before).toBeCloseTo(3, 6);
});
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
const budget = await getBudgetUsd();
// Under budget → null (allowed)
expect(await assertBudgetAvailable()).toBeNull();
// Push spend over budget for this month, then it must block with 402.
// Tag with a unique marker so we can delete EXACTLY this row at the end of
// the test — that keeps the inflated spend from leaking into any other
// current-month test regardless of execution order (the beforeEach kind=KIND
// cleanup would catch it too, but cleaning in-test makes it order-proof).
const overBudget = await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
input_tokens: 0,
output_tokens: 0,
cost_usd: budget + 1,
created_at: new Date(),
},
});
try {
const blocked = await assertBudgetAvailable();
expect(blocked).not.toBeNull();
expect(blocked?.status).toBe(402);
} finally {
// Remove the over-budget row immediately so it cannot inflate
// getMonthSpendUsd for any subsequently-running test.
await prisma.ai_usage
.delete({ where: { id: overBudget.id } })
.catch(() => {});
}
});
it("isConfigured reflects the API key presence", () => {
expect(typeof isConfigured()).toBe("boolean");
});
});
describe("ai.service conversations", () => {
beforeEach(async () => {
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
});
// Narrow a service result to its error branch with a clear failure message.
const expectError = (
r: { error: string; status: number } | { data: unknown },
status: number,
) => {
if (!("error" in r))
throw new Error(`expected an error result, got ${JSON.stringify(r)}`);
expect(r.status).toBe(status);
};
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
const conv = await createConversation(CONV_UID_A);
expect(conv.title).toBe("Nová konverzace");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "Kolik je hodin v Praze?" },
{ role: "assistant", content: "Nevím, ale…" },
]);
const list = await listConversations(CONV_UID_A);
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
if (!("data" in msgs)) throw new Error("expected messages, got an error");
expect(msgs.data.map((m) => m.content)).toEqual([
"Kolik je hodin v Praze?",
"Nevím, ale…",
]);
});
it("does not overwrite a renamed title on later appends", async () => {
const conv = await createConversation(CONV_UID_A);
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "tohle by nemělo přepsat název" },
]);
const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
});
it("bumps updated_at on append", async () => {
const conv = await createConversation(CONV_UID_A);
// Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
// second-precision, so compare against a year rather than ms deltas).
await prisma.ai_conversations.update({
where: { id: conv.id },
data: { updated_at: new Date("2000-01-01T00:00:00Z") },
});
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "ping" },
]);
const after = (await listConversations(CONV_UID_A))[0].updated_at;
expect(after.getFullYear()).toBeGreaterThan(2000);
});
it("isolates conversations per user (404 on every op across owners)", async () => {
const a = await createConversation(CONV_UID_A);
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
expectError(await getConversationMessages(CONV_UID_B, a.id), 404);
expectError(
await appendConversationMessages(CONV_UID_B, a.id, [
{ role: "user", content: "x" },
]),
404,
);
expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404);
expectError(await deleteConversation(CONV_UID_B, a.id), 404);
});
it("cascade-deletes messages with the conversation", async () => {
const conv = await createConversation(CONV_UID_A);
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "x" },
]);
await deleteConversation(CONV_UID_A, conv.id);
const remaining = await prisma.ai_chat_messages.count({
where: { conversation_id: conv.id },
});
expect(remaining).toBe(0);
});
});
describe("HTTP /api/admin/ai", () => {
let app: Awaited<ReturnType<typeof buildAiApp>>;
let adminToken: string;
let noPermToken: string;
let noPermRoleId: number;
let noPermUserId: number;
let savedKey: string;
async function buildAiApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
return a;
}
function token(user: {
id: number;
username: string;
roleName: string | null;
}) {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
appConfig.jwt.secret,
{ expiresIn: "15m" },
);
}
beforeAll(async () => {
savedKey = appConfig.anthropic.apiKey;
app = await buildAiApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("admin not found");
adminToken = token({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
const stamp = Date.now();
const role = await prisma.roles.create({
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
});
noPermRoleId = role.id;
const u = await prisma.users.create({
data: {
username: `noperm_ai_${stamp}`,
first_name: "No",
last_name: "Perm",
email: `noperm_ai_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
noPermUserId = u.id;
noPermToken = token({
id: u.id,
username: u.username,
roleName: role.name,
});
});
afterAll(async () => {
if (app) await app.close();
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
if (noPermUserId)
await prisma.users
.deleteMany({ where: { id: noPermUserId } })
.catch(() => {});
if (noPermRoleId)
await prisma.roles
.deleteMany({ where: { id: noPermRoleId } })
.catch(() => {});
});
it("GET /usage requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/usage",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("GET /usage returns spend + budget for an admin", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/usage",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(typeof body.data.budget_usd).toBe("number");
expect(typeof body.data.month_spend_usd).toBe("number");
});
it("POST /chat returns 503 when AI is not configured", async () => {
(appConfig.anthropic as { apiKey: string }).apiKey = "";
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/chat",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(res.statusCode).toBe(503);
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
});
it("GET /conversations requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} t` },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.conversation.id as number;
const appended = await app.inject({
method: "POST",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(appended.statusCode).toBe(200);
const msgs = await app.inject({
method: "GET",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(
msgs.json().data.messages.map((m: { content: string }) => m.content),
).toContain("ahoj");
const renamed = await app.inject({
method: "PATCH",
url: `/api/admin/ai/conversations/${id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} renamed` },
});
expect(renamed.json().data.conversation.title).toBe(
`${HIST_MARKER} renamed`,
);
const del = await app.inject({
method: "DELETE",
url: `/api/admin/ai/conversations/${id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
});
it("GET messages of a non-existent conversation → 404", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations/999999000/messages",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
it("POST /conversations requires ai.use", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${noPermToken}`,
"Content-Type": "application/json",
},
payload: { title: "x" },
});
expect(res.statusCode).toBe(403);
});
it("GET /conversations lists the user's conversations", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} listed` },
});
const id = created.json().data.conversation.id as number;
const list = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(list.statusCode).toBe(200);
expect(
list.json().data.conversations.map((c: { id: number }) => c.id),
).toContain(id);
});
it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => {
const patch = await app.inject({
method: "PATCH",
url: "/api/admin/ai/conversations/999999000",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: "" },
});
expect(patch.statusCode).toBe(400);
const append = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations/999999000/messages",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [] },
});
expect(append.statusCode).toBe(400);
});
});