506 lines
20 KiB
TypeScript
506 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
|
import {
|
|
ASSISTANT_CONFIRMATION_PREFIX,
|
|
canExecuteMutationTool,
|
|
clearPendingAssistantApproval,
|
|
consumePendingAssistantApproval,
|
|
createPendingAssistantApproval,
|
|
getAvailableAssistantTools,
|
|
listPendingAssistantApprovals,
|
|
peekPendingAssistantApproval,
|
|
} from "../router/assistant.js";
|
|
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
|
|
|
function getToolNames(
|
|
permissions: PermissionKeyValue[],
|
|
userRole: SystemRole = SystemRole.ADMIN,
|
|
) {
|
|
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
|
}
|
|
|
|
const TEST_USER_ID = "assistant-test-user";
|
|
const TEST_CONVERSATION_ID = "assistant-test-conversation";
|
|
|
|
function createApprovalStoreMock() {
|
|
const records = new Map<string, {
|
|
id: string;
|
|
userId: string;
|
|
conversationId: string;
|
|
toolName: string;
|
|
toolArguments: string;
|
|
summary: string;
|
|
status: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
|
approvedAt: Date | null;
|
|
cancelledAt: Date | null;
|
|
createdAt: Date;
|
|
expiresAt: Date;
|
|
updatedAt: Date;
|
|
}>();
|
|
|
|
return {
|
|
assistantApproval: {
|
|
findFirst: vi.fn(async ({
|
|
where,
|
|
orderBy,
|
|
}: {
|
|
where: {
|
|
id?: string;
|
|
userId?: string;
|
|
conversationId?: string;
|
|
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
|
};
|
|
orderBy?: { createdAt: "desc" | "asc" };
|
|
}) => {
|
|
const matches = [...records.values()]
|
|
.filter((record) => (
|
|
(!where.id || record.id === where.id)
|
|
&& (!where.userId || record.userId === where.userId)
|
|
&& (!where.conversationId || record.conversationId === where.conversationId)
|
|
&& (!where.status || record.status === where.status)
|
|
))
|
|
.sort((a, b) => (
|
|
orderBy?.createdAt === "asc"
|
|
? a.createdAt.getTime() - b.createdAt.getTime()
|
|
: b.createdAt.getTime() - a.createdAt.getTime()
|
|
));
|
|
return matches[0] ?? null;
|
|
}),
|
|
findMany: vi.fn(async ({
|
|
where,
|
|
orderBy,
|
|
}: {
|
|
where: {
|
|
userId?: string;
|
|
conversationId?: string;
|
|
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
|
expiresAt?: { lte?: Date; gt?: Date };
|
|
};
|
|
orderBy?: { createdAt: "desc" | "asc" };
|
|
}) => (
|
|
[...records.values()]
|
|
.filter((record) => (
|
|
(!where.userId || record.userId === where.userId)
|
|
&& (!where.conversationId || record.conversationId === where.conversationId)
|
|
&& (!where.status || record.status === where.status)
|
|
&& (!where.expiresAt?.lte || record.expiresAt <= where.expiresAt.lte)
|
|
&& (!where.expiresAt?.gt || record.expiresAt > where.expiresAt.gt)
|
|
))
|
|
.sort((a, b) => (
|
|
orderBy?.createdAt === "asc"
|
|
? a.createdAt.getTime() - b.createdAt.getTime()
|
|
: b.createdAt.getTime() - a.createdAt.getTime()
|
|
))
|
|
)),
|
|
create: vi.fn(async ({
|
|
data,
|
|
}: {
|
|
data: {
|
|
userId: string;
|
|
conversationId: string;
|
|
toolName: string;
|
|
toolArguments: string;
|
|
summary: string;
|
|
createdAt: Date;
|
|
expiresAt: Date;
|
|
};
|
|
}) => {
|
|
const record = {
|
|
id: `approval-${records.size + 1}`,
|
|
...data,
|
|
status: "PENDING" as const,
|
|
approvedAt: null,
|
|
cancelledAt: null,
|
|
updatedAt: data.createdAt,
|
|
};
|
|
records.set(record.id, record);
|
|
return record;
|
|
}),
|
|
updateMany: vi.fn(async ({
|
|
where,
|
|
data,
|
|
}: {
|
|
where: {
|
|
id?: string;
|
|
userId?: string;
|
|
conversationId?: string;
|
|
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
|
|
expiresAt?: { lte?: Date; gt?: Date };
|
|
};
|
|
data: Partial<{
|
|
status: "APPROVED" | "CANCELLED" | "EXPIRED";
|
|
cancelledAt: Date;
|
|
approvedAt: Date;
|
|
}>;
|
|
}) => {
|
|
let count = 0;
|
|
for (const [id, record] of records.entries()) {
|
|
if (where.id && record.id !== where.id) continue;
|
|
if (where.userId && record.userId !== where.userId) continue;
|
|
if (where.conversationId && record.conversationId !== where.conversationId) continue;
|
|
if (where.status && record.status !== where.status) continue;
|
|
if (where.expiresAt?.lte && record.expiresAt > where.expiresAt.lte) continue;
|
|
if (where.expiresAt?.gt && record.expiresAt <= where.expiresAt.gt) continue;
|
|
records.set(id, {
|
|
...record,
|
|
...data,
|
|
updatedAt: new Date(),
|
|
});
|
|
count += 1;
|
|
}
|
|
return { count };
|
|
}),
|
|
update: vi.fn(async ({
|
|
where,
|
|
data,
|
|
}: {
|
|
where: { id: string };
|
|
data: {
|
|
status: "APPROVED";
|
|
approvedAt: Date;
|
|
};
|
|
}) => {
|
|
const record = records.get(where.id);
|
|
if (!record) throw new Error("Record not found");
|
|
const next = {
|
|
...record,
|
|
...data,
|
|
updatedAt: new Date(),
|
|
};
|
|
records.set(where.id, next);
|
|
return next;
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("assistant router tool gating", () => {
|
|
let approvalStore = createApprovalStoreMock();
|
|
|
|
beforeEach(() => {
|
|
approvalStore = createApprovalStoreMock();
|
|
});
|
|
|
|
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
|
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
|
|
const withAdvanced = getToolNames([
|
|
PermissionKey.VIEW_COSTS,
|
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
|
]);
|
|
|
|
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
|
expect(withAdvanced).toContain("find_best_project_resource");
|
|
expect(withAdvanced).toContain("get_chargeability_report");
|
|
expect(withAdvanced).toContain("get_resource_computation_graph");
|
|
expect(withAdvanced).toContain("get_project_computation_graph");
|
|
});
|
|
|
|
it("keeps user administration tools behind manageUsers", () => {
|
|
const withoutManageUsers = getToolNames([]);
|
|
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
|
|
|
|
expect(withoutManageUsers).not.toContain("list_users");
|
|
expect(withManageUsers).toContain("list_users");
|
|
});
|
|
|
|
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
|
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
|
|
|
expect(names).not.toContain("find_best_project_resource");
|
|
expect(names).not.toContain("get_chargeability_report");
|
|
expect(names).not.toContain("get_resource_computation_graph");
|
|
expect(names).not.toContain("get_project_computation_graph");
|
|
});
|
|
|
|
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
|
|
const controllerNames = getToolNames([
|
|
PermissionKey.VIEW_COSTS,
|
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
|
], SystemRole.CONTROLLER);
|
|
const userNames = getToolNames([
|
|
PermissionKey.VIEW_COSTS,
|
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
|
], SystemRole.USER);
|
|
|
|
expect(controllerNames).toContain("get_chargeability_report");
|
|
expect(controllerNames).toContain("get_resource_computation_graph");
|
|
expect(controllerNames).toContain("get_project_computation_graph");
|
|
expect(userNames).not.toContain("get_chargeability_report");
|
|
expect(userNames).not.toContain("get_resource_computation_graph");
|
|
expect(userNames).not.toContain("get_project_computation_graph");
|
|
});
|
|
|
|
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
|
|
const managerNames = getToolNames([
|
|
PermissionKey.MANAGE_ALLOCATIONS,
|
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
|
], SystemRole.MANAGER);
|
|
const userNames = getToolNames([
|
|
PermissionKey.MANAGE_ALLOCATIONS,
|
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
|
], SystemRole.USER);
|
|
const missingAdvancedNames = getToolNames([
|
|
PermissionKey.MANAGE_ALLOCATIONS,
|
|
], SystemRole.MANAGER);
|
|
|
|
expect(managerNames).toContain("update_timeline_allocation_inline");
|
|
expect(managerNames).toContain("apply_timeline_project_shift");
|
|
expect(managerNames).toContain("quick_assign_timeline_resource");
|
|
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
|
|
expect(managerNames).toContain("batch_shift_timeline_allocations");
|
|
expect(userNames).not.toContain("update_timeline_allocation_inline");
|
|
expect(userNames).not.toContain("apply_timeline_project_shift");
|
|
expect(userNames).not.toContain("quick_assign_timeline_resource");
|
|
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
|
|
expect(userNames).not.toContain("batch_shift_timeline_allocations");
|
|
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
|
|
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
|
});
|
|
|
|
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
|
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
|
|
|
expect(adminNames).toContain("list_holiday_calendars");
|
|
expect(adminNames).toContain("get_holiday_calendar");
|
|
expect(adminNames).toContain("preview_resolved_holiday_calendar");
|
|
expect(adminNames).toContain("create_holiday_calendar");
|
|
expect(managerNames).toContain("list_holiday_calendars");
|
|
expect(managerNames).toContain("get_holiday_calendar");
|
|
expect(managerNames).toContain("preview_resolved_holiday_calendar");
|
|
expect(managerNames).not.toContain("create_holiday_calendar");
|
|
expect(managerNames).not.toContain("update_holiday_calendar");
|
|
expect(managerNames).not.toContain("delete_holiday_calendar");
|
|
expect(managerNames).not.toContain("create_holiday_calendar_entry");
|
|
expect(managerNames).not.toContain("update_holiday_calendar_entry");
|
|
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
|
|
});
|
|
|
|
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
|
|
const adminNames = getToolNames([], SystemRole.ADMIN);
|
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
|
|
|
expect(adminNames).toContain("list_countries");
|
|
expect(adminNames).toContain("get_country");
|
|
expect(adminNames).toContain("create_country");
|
|
expect(adminNames).toContain("update_country");
|
|
expect(adminNames).toContain("create_metro_city");
|
|
expect(adminNames).toContain("update_metro_city");
|
|
expect(adminNames).toContain("delete_metro_city");
|
|
expect(managerNames).toContain("list_countries");
|
|
expect(managerNames).toContain("get_country");
|
|
expect(managerNames).not.toContain("create_country");
|
|
expect(managerNames).not.toContain("update_country");
|
|
expect(managerNames).not.toContain("create_metro_city");
|
|
expect(managerNames).not.toContain("update_metro_city");
|
|
expect(managerNames).not.toContain("delete_metro_city");
|
|
});
|
|
|
|
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
|
|
expect(canExecuteMutationTool([
|
|
{ role: "user", content: "Lege bitte ein Projekt an" },
|
|
], "create_project")).toBe(false);
|
|
|
|
expect(canExecuteMutationTool([
|
|
{ role: "user", content: "Lege bitte ein Projekt an" },
|
|
{ role: "assistant", content: "Ich werde jetzt das Projekt erstellen." },
|
|
{ role: "user", content: "ja" },
|
|
], "create_project")).toBe(false);
|
|
|
|
expect(canExecuteMutationTool([
|
|
{ role: "user", content: "Lege bitte ein Projekt an" },
|
|
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} Ich werde das Projekt \"Apollo\" in DRAFT anlegen. Bitte bestätigen.` },
|
|
{ role: "user", content: "ja, bitte ausführen" },
|
|
], "create_project")).toBe(true);
|
|
});
|
|
|
|
it("requires a matching server-side pending approval for mutation execution when provided", async () => {
|
|
const pendingApproval = await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
TEST_CONVERSATION_ID,
|
|
"create_project",
|
|
JSON.stringify({ name: "Apollo", status: "DRAFT" }),
|
|
);
|
|
|
|
expect(canExecuteMutationTool([
|
|
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
|
|
{ role: "user", content: "ja" },
|
|
], "create_project", pendingApproval)).toBe(true);
|
|
|
|
expect(canExecuteMutationTool([
|
|
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
|
|
{ role: "user", content: "ja" },
|
|
], "delete_project", pendingApproval)).toBe(false);
|
|
});
|
|
|
|
it("stores and consumes pending approvals independently from chat text", async () => {
|
|
const approval = await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
TEST_CONVERSATION_ID,
|
|
"create_project",
|
|
JSON.stringify({ name: "Gelddruckmaschine", status: "DRAFT" }),
|
|
);
|
|
|
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
|
|
id: approval.id,
|
|
toolName: "create_project",
|
|
summary: expect.stringContaining("create project"),
|
|
});
|
|
|
|
await expect(consumePendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
|
|
id: approval.id,
|
|
toolName: "create_project",
|
|
});
|
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it("expires stale pending approvals", async () => {
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
TEST_CONVERSATION_ID,
|
|
"create_project",
|
|
JSON.stringify({ name: "Apollo" }),
|
|
{ ttlMs: -1 },
|
|
);
|
|
|
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it("clears pending approvals for cancellation semantics", async () => {
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
TEST_CONVERSATION_ID,
|
|
"create_project",
|
|
JSON.stringify({ name: "Apollo" }),
|
|
);
|
|
|
|
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
|
|
|
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it("isolates pending approvals by conversation", async () => {
|
|
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
|
|
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
TEST_CONVERSATION_ID,
|
|
"create_project",
|
|
JSON.stringify({ name: "Apollo" }),
|
|
);
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
otherConversationId,
|
|
"create_project",
|
|
JSON.stringify({ name: "Hermes" }),
|
|
);
|
|
|
|
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
|
|
|
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
|
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, otherConversationId)).resolves.toMatchObject({
|
|
toolName: "create_project",
|
|
summary: expect.stringContaining("Hermes"),
|
|
});
|
|
});
|
|
|
|
it("lists only still-pending approvals for the current user across conversations", async () => {
|
|
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
|
|
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
TEST_CONVERSATION_ID,
|
|
"create_project",
|
|
JSON.stringify({ name: "Apollo" }),
|
|
);
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
otherConversationId,
|
|
"create_project",
|
|
JSON.stringify({ name: "Hermes" }),
|
|
);
|
|
|
|
const cancelled = await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
`${TEST_CONVERSATION_ID}-cancelled`,
|
|
"create_project",
|
|
JSON.stringify({ name: "Cancelled" }),
|
|
);
|
|
await approvalStore.assistantApproval.updateMany({
|
|
where: { id: cancelled.id, userId: TEST_USER_ID, status: "PENDING" },
|
|
data: { status: "CANCELLED", cancelledAt: new Date() },
|
|
});
|
|
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
"other-user",
|
|
`${TEST_CONVERSATION_ID}-foreign`,
|
|
"create_project",
|
|
JSON.stringify({ name: "Foreign" }),
|
|
);
|
|
await createPendingAssistantApproval(
|
|
approvalStore,
|
|
TEST_USER_ID,
|
|
`${TEST_CONVERSATION_ID}-expired`,
|
|
"create_project",
|
|
JSON.stringify({ name: "Expired" }),
|
|
{ ttlMs: -1 },
|
|
);
|
|
|
|
const approvals = await listPendingAssistantApprovals(approvalStore, TEST_USER_ID);
|
|
const approvalSummaries = approvals.map((approval) => approval.summary).join(" ");
|
|
|
|
expect(approvals).toHaveLength(2);
|
|
expect([...approvals.map((approval) => approval.conversationId)].sort()).toEqual([
|
|
otherConversationId,
|
|
TEST_CONVERSATION_ID,
|
|
].sort());
|
|
expect(approvals.every((approval) => approval.userId === TEST_USER_ID)).toBe(true);
|
|
expect(approvalSummaries).toContain("Apollo");
|
|
expect(approvalSummaries).toContain("Hermes");
|
|
expect(approvalSummaries).not.toContain("Cancelled");
|
|
expect(approvalSummaries).not.toContain("Expired");
|
|
expect(approvalSummaries).not.toContain("Foreign");
|
|
});
|
|
|
|
it("does not require confirmation for read-only assistant tools", () => {
|
|
expect(canExecuteMutationTool([
|
|
{ role: "user", content: "Zeig mir meine Notifications" },
|
|
], "list_notifications")).toBe(true);
|
|
});
|
|
|
|
it("keeps assistant tool descriptions aligned with runtime permissions", () => {
|
|
const toolDescriptions = new Map(
|
|
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function.description]),
|
|
);
|
|
|
|
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
|
|
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
|
|
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
|
|
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
|
|
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
|
|
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
|
|
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
|
|
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
|
|
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
|
|
expect(toolDescriptions.get("get_chargeability_report")).toContain("controller/manager/admin");
|
|
expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts");
|
|
expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools");
|
|
expect(toolDescriptions.get("get_project_computation_graph")).toContain("controller/manager/admin");
|
|
expect(toolDescriptions.get("update_timeline_allocation_inline")).toContain("manager/admin");
|
|
expect(toolDescriptions.get("apply_timeline_project_shift")).toContain("manageAllocations");
|
|
expect(toolDescriptions.get("quick_assign_timeline_resource")).toContain("useAssistantAdvancedTools");
|
|
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
|
|
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
|
|
});
|
|
});
|