refactor(api): modularize assistant router workflow
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const TEST_USER_ID = "assistant-test-user";
|
||||
export const TEST_CONVERSATION_ID = "assistant-test-conversation";
|
||||
|
||||
export 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;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMissingApprovalTableError() {
|
||||
return Object.assign(
|
||||
new Error("The table `public.assistant_approvals` does not exist in the current database."),
|
||||
{
|
||||
code: "P2021",
|
||||
meta: { table: "public.assistant_approvals" },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AssistantApprovalStorageUnavailableError,
|
||||
clearPendingAssistantApproval,
|
||||
consumePendingAssistantApproval,
|
||||
createPendingAssistantApproval,
|
||||
listPendingAssistantApprovals,
|
||||
peekPendingAssistantApproval,
|
||||
resetAssistantApprovalStorageWarningStateForTests,
|
||||
} from "../router/assistant-approvals.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import {
|
||||
createApprovalStoreMock,
|
||||
createMissingApprovalTableError,
|
||||
TEST_CONVERSATION_ID,
|
||||
TEST_USER_ID,
|
||||
} from "./assistant-approval-test-helpers.js";
|
||||
|
||||
describe("assistant approvals", () => {
|
||||
let approvalStore = createApprovalStoreMock();
|
||||
|
||||
beforeEach(() => {
|
||||
approvalStore = createApprovalStoreMock();
|
||||
resetAssistantApprovalStorageWarningStateForTests();
|
||||
});
|
||||
|
||||
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("degrades approval reads gracefully when approval storage is missing", async () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger);
|
||||
const missingTableError = createMissingApprovalTableError();
|
||||
const missingStore = {
|
||||
assistantApproval: {
|
||||
findFirst: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
findMany: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
create: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
updateMany: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]);
|
||||
await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||
await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
|
||||
await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an explicit error when approval storage is missing for mutation confirmation", async () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger);
|
||||
const missingTableError = createMissingApprovalTableError();
|
||||
const missingStore = {
|
||||
assistantApproval: {
|
||||
findFirst: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
findMany: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
create: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
updateMany: vi.fn(async () => {
|
||||
throw missingTableError;
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(createPendingAssistantApproval(
|
||||
missingStore,
|
||||
TEST_USER_ID,
|
||||
TEST_CONVERSATION_ID,
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo" }),
|
||||
)).rejects.toBeInstanceOf(AssistantApprovalStorageUnavailableError);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASSISTANT_CONFIRMATION_PREFIX,
|
||||
buildApprovalSummary,
|
||||
canExecuteMutationTool,
|
||||
formatApprovalValue,
|
||||
isAffirmativeConfirmationReply,
|
||||
isCancellationReply,
|
||||
parseToolArguments,
|
||||
} from "../router/assistant-confirmation.js";
|
||||
|
||||
describe("assistant confirmation", () => {
|
||||
it("parses tool arguments defensively", () => {
|
||||
expect(parseToolArguments("{\"name\":\"Apollo\",\"status\":\"DRAFT\"}")).toEqual({
|
||||
name: "Apollo",
|
||||
status: "DRAFT",
|
||||
});
|
||||
expect(parseToolArguments("[]")).toEqual({});
|
||||
expect(parseToolArguments("not-json")).toEqual({});
|
||||
});
|
||||
|
||||
it("formats approval values compactly", () => {
|
||||
expect(formatApprovalValue("Apollo")).toBe("Apollo");
|
||||
expect(formatApprovalValue("x".repeat(60))).toBe(`${"x".repeat(45)}...`);
|
||||
expect(formatApprovalValue([1, "two", true, "four"])).toBe("[1, two, true, ...]");
|
||||
expect(formatApprovalValue({ nested: true })).toBe("{...}");
|
||||
});
|
||||
|
||||
it("builds compact approval summaries from tool calls", () => {
|
||||
expect(buildApprovalSummary(
|
||||
"create_project",
|
||||
JSON.stringify({ name: "Apollo", status: "DRAFT", budgetCents: 1200000 }),
|
||||
)).toBe("create project (name=Apollo, status=DRAFT, budgetCents=1200000)");
|
||||
expect(buildApprovalSummary("delete_project", "{}")).toBe("delete project");
|
||||
});
|
||||
|
||||
it("recognizes affirmative and cancellation replies in multiple phrasings", () => {
|
||||
expect(isAffirmativeConfirmationReply("ja, bitte ausführen")).toBe(true);
|
||||
expect(isAffirmativeConfirmationReply("go ahead")).toBe(true);
|
||||
expect(isAffirmativeConfirmationReply("vielleicht")).toBe(false);
|
||||
|
||||
expect(isCancellationReply("nein, abbrechen")).toBe(true);
|
||||
expect(isCancellationReply("stop")).toBe(true);
|
||||
expect(isCancellationReply("später")).toBe(false);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const pendingApproval = {
|
||||
id: "approval-1",
|
||||
userId: "user-1",
|
||||
conversationId: "conversation-1",
|
||||
toolName: "create_project",
|
||||
toolArguments: JSON.stringify({ name: "Apollo", status: "DRAFT" }),
|
||||
summary: "create project (name=Apollo, status=DRAFT)",
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
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("does not require confirmation for read-only assistant tools", () => {
|
||||
expect(canExecuteMutationTool([
|
||||
{ role: "user", content: "Zeig mir meine Notifications" },
|
||||
], "list_notifications")).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.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);
|
||||
}
|
||||
|
||||
describe("assistant tool policy", () => {
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
]);
|
||||
const withAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
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 self-service tools available to plain authenticated users", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(userNames).toContain("get_current_user");
|
||||
expect(userNames).toContain("get_dashboard_layout");
|
||||
expect(userNames).toContain("save_dashboard_layout");
|
||||
expect(userNames).toContain("get_favorite_project_ids");
|
||||
expect(userNames).toContain("toggle_favorite_project");
|
||||
expect(userNames).toContain("get_column_preferences");
|
||||
expect(userNames).toContain("set_column_preferences");
|
||||
expect(userNames).toContain("get_mfa_status");
|
||||
expect(userNames).toContain("list_notifications");
|
||||
expect(userNames).toContain("get_unread_notification_count");
|
||||
expect(userNames).toContain("list_tasks");
|
||||
expect(userNames).toContain("get_task_counts");
|
||||
expect(userNames).toContain("create_reminder");
|
||||
expect(userNames).toContain("list_reminders");
|
||||
expect(userNames).toContain("update_reminder");
|
||||
expect(userNames).toContain("delete_reminder");
|
||||
});
|
||||
|
||||
it("keeps admin-only user tools hidden from non-admin roles", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_users");
|
||||
expect(adminNames).toContain("get_active_user_count");
|
||||
expect(adminNames).toContain("create_user");
|
||||
expect(adminNames).toContain("set_user_password");
|
||||
expect(adminNames).toContain("update_user_role");
|
||||
expect(adminNames).toContain("update_user_name");
|
||||
expect(adminNames).toContain("link_user_resource");
|
||||
expect(adminNames).toContain("auto_link_users_by_email");
|
||||
expect(adminNames).toContain("set_user_permissions");
|
||||
expect(adminNames).toContain("reset_user_permissions");
|
||||
expect(adminNames).toContain("get_effective_user_permissions");
|
||||
expect(adminNames).toContain("disable_user_totp");
|
||||
|
||||
expect(managerNames).not.toContain("list_users");
|
||||
expect(managerNames).not.toContain("create_user");
|
||||
expect(managerNames).not.toContain("set_user_permissions");
|
||||
expect(managerNames).not.toContain("disable_user_totp");
|
||||
expect(userNames).not.toContain("list_users");
|
||||
expect(userNames).not.toContain("get_active_user_count");
|
||||
expect(userNames).not.toContain("create_user");
|
||||
expect(userNames).not.toContain("set_user_password");
|
||||
expect(userNames).not.toContain("update_user_role");
|
||||
expect(userNames).not.toContain("update_user_name");
|
||||
expect(userNames).not.toContain("link_user_resource");
|
||||
expect(userNames).not.toContain("auto_link_users_by_email");
|
||||
expect(userNames).not.toContain("set_user_permissions");
|
||||
expect(userNames).not.toContain("reset_user_permissions");
|
||||
expect(userNames).not.toContain("get_effective_user_permissions");
|
||||
expect(userNames).not.toContain("disable_user_totp");
|
||||
});
|
||||
|
||||
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(managerNames).toContain("list_assignable_users");
|
||||
expect(managerNames).toContain("create_notification");
|
||||
expect(managerNames).toContain("create_task_for_user");
|
||||
expect(managerNames).toContain("assign_task");
|
||||
expect(managerNames).toContain("send_broadcast");
|
||||
expect(managerNames).toContain("list_broadcasts");
|
||||
expect(managerNames).toContain("get_broadcast_detail");
|
||||
expect(adminNames).toContain("list_assignable_users");
|
||||
expect(adminNames).toContain("create_task_for_user");
|
||||
expect(adminNames).toContain("send_broadcast");
|
||||
expect(userNames).not.toContain("list_assignable_users");
|
||||
expect(userNames).not.toContain("create_notification");
|
||||
expect(userNames).not.toContain("create_task_for_user");
|
||||
expect(userNames).not.toContain("assign_task");
|
||||
expect(userNames).not.toContain("send_broadcast");
|
||||
expect(userNames).not.toContain("list_broadcasts");
|
||||
expect(userNames).not.toContain("get_broadcast_detail");
|
||||
});
|
||||
|
||||
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("query_change_history");
|
||||
expect(controllerNames).toContain("get_entity_timeline");
|
||||
expect(controllerNames).toContain("search_by_skill");
|
||||
expect(controllerNames).toContain("export_resources_csv");
|
||||
expect(controllerNames).toContain("export_projects_csv");
|
||||
expect(controllerNames).toContain("list_audit_log_entries");
|
||||
expect(controllerNames).toContain("get_audit_log_entry");
|
||||
expect(controllerNames).toContain("get_audit_log_timeline");
|
||||
expect(controllerNames).toContain("get_audit_activity_summary");
|
||||
expect(controllerNames).toContain("get_chargeability_report");
|
||||
expect(controllerNames).toContain("get_resource_computation_graph");
|
||||
expect(controllerNames).toContain("get_project_computation_graph");
|
||||
expect(userNames).not.toContain("query_change_history");
|
||||
expect(userNames).not.toContain("get_entity_timeline");
|
||||
expect(userNames).not.toContain("search_by_skill");
|
||||
expect(userNames).not.toContain("export_resources_csv");
|
||||
expect(userNames).not.toContain("export_projects_csv");
|
||||
expect(userNames).not.toContain("list_audit_log_entries");
|
||||
expect(userNames).not.toContain("get_audit_log_entry");
|
||||
expect(userNames).not.toContain("get_audit_log_timeline");
|
||||
expect(userNames).not.toContain("get_audit_activity_summary");
|
||||
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 entity-scoped comment tools available to plain authenticated users", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(userNames).toContain("list_comments");
|
||||
expect(userNames).toContain("create_comment");
|
||||
expect(userNames).toContain("resolve_comment");
|
||||
});
|
||||
|
||||
it("keeps planning read tools behind the explicit planning permission", () => {
|
||||
const userWithoutPlanning = getToolNames([], SystemRole.USER);
|
||||
const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||
|
||||
expect(userWithoutPlanning).not.toContain("list_allocations");
|
||||
expect(userWithoutPlanning).not.toContain("list_demands");
|
||||
expect(userWithoutPlanning).not.toContain("list_blueprints");
|
||||
expect(userWithoutPlanning).not.toContain("get_blueprint");
|
||||
expect(userWithoutPlanning).not.toContain("list_clients");
|
||||
expect(userWithoutPlanning).not.toContain("list_roles");
|
||||
expect(userWithoutPlanning).not.toContain("list_management_levels");
|
||||
expect(userWithoutPlanning).not.toContain("list_utilization_categories");
|
||||
expect(userWithoutPlanning).not.toContain("check_resource_availability");
|
||||
expect(userWithoutPlanning).not.toContain("find_capacity");
|
||||
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
||||
expect(userWithPlanning).toContain("list_allocations");
|
||||
expect(userWithPlanning).toContain("list_demands");
|
||||
expect(userWithPlanning).toContain("list_blueprints");
|
||||
expect(userWithPlanning).toContain("get_blueprint");
|
||||
expect(userWithPlanning).toContain("list_clients");
|
||||
expect(userWithPlanning).toContain("list_roles");
|
||||
expect(userWithPlanning).toContain("list_management_levels");
|
||||
expect(userWithPlanning).toContain("list_utilization_categories");
|
||||
expect(userWithPlanning).toContain("check_resource_availability");
|
||||
expect(userWithPlanning).toContain("find_capacity");
|
||||
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithPlanning).not.toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
|
||||
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||
const planningAndCosts = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
], SystemRole.USER);
|
||||
const planningCostsAndAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(planningOnly).not.toContain("get_staffing_suggestions");
|
||||
expect(planningOnly).not.toContain("find_best_project_resource");
|
||||
expect(planningAndCosts).toContain("get_staffing_suggestions");
|
||||
expect(planningAndCosts).not.toContain("find_best_project_resource");
|
||||
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
|
||||
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps controller-only project and dashboard reads hidden from plain users", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("search_projects");
|
||||
expect(controllerNames).toContain("get_project");
|
||||
expect(controllerNames).toContain("get_statistics");
|
||||
expect(controllerNames).toContain("get_dashboard_detail");
|
||||
expect(controllerNames).toContain("get_skill_gaps");
|
||||
expect(controllerNames).toContain("get_project_health");
|
||||
expect(controllerNames).toContain("get_budget_forecast");
|
||||
expect(controllerNames).toContain("get_budget_status");
|
||||
expect(controllerNames).toContain("get_shoring_ratio");
|
||||
expect(userNames).not.toContain("search_projects");
|
||||
expect(userNames).not.toContain("get_project");
|
||||
expect(userNames).not.toContain("get_statistics");
|
||||
expect(userNames).not.toContain("get_dashboard_detail");
|
||||
expect(userNames).not.toContain("get_skill_gaps");
|
||||
expect(userNames).not.toContain("get_project_health");
|
||||
expect(userNames).not.toContain("get_budget_forecast");
|
||||
expect(userNames).not.toContain("get_budget_status");
|
||||
expect(userNames).not.toContain("get_shoring_ratio");
|
||||
});
|
||||
|
||||
it("keeps legacy controller-only analysis and report tools hidden from plain users", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("detect_anomalies");
|
||||
expect(controllerNames).toContain("get_insights_summary");
|
||||
expect(controllerNames).toContain("run_report");
|
||||
expect(controllerNames).toContain("lookup_rate");
|
||||
expect(controllerNames).toContain("simulate_scenario");
|
||||
expect(controllerNames).toContain("generate_project_narrative");
|
||||
expect(controllerNames).toContain("list_rate_cards");
|
||||
expect(controllerNames).toContain("resolve_rate");
|
||||
expect(userNames).not.toContain("detect_anomalies");
|
||||
expect(userNames).not.toContain("get_insights_summary");
|
||||
expect(userNames).not.toContain("run_report");
|
||||
expect(userNames).not.toContain("lookup_rate");
|
||||
expect(userNames).not.toContain("simulate_scenario");
|
||||
expect(userNames).not.toContain("generate_project_narrative");
|
||||
expect(userNames).not.toContain("list_rate_cards");
|
||||
expect(userNames).not.toContain("resolve_rate");
|
||||
});
|
||||
|
||||
it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => {
|
||||
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||
const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||
|
||||
expect(controllerWithoutCosts).not.toContain("list_rate_cards");
|
||||
expect(controllerWithoutCosts).not.toContain("resolve_rate");
|
||||
expect(controllerWithCosts).toContain("list_rate_cards");
|
||||
expect(controllerWithCosts).toContain("resolve_rate");
|
||||
});
|
||||
|
||||
it("requires both controller role and advanced assistant access for timeline detail tools", () => {
|
||||
const controllerWithAdvanced = getToolNames([
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER);
|
||||
const userWithAdvanced = getToolNames([
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerWithAdvanced).toContain("get_timeline_entries_view");
|
||||
expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays");
|
||||
expect(controllerWithAdvanced).toContain("get_project_timeline_context");
|
||||
expect(controllerWithAdvanced).toContain("preview_project_shift");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context");
|
||||
expect(controllerWithoutAdvanced).not.toContain("preview_project_shift");
|
||||
expect(userWithAdvanced).not.toContain("get_timeline_entries_view");
|
||||
expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||
expect(userWithAdvanced).not.toContain("get_project_timeline_context");
|
||||
expect(userWithAdvanced).not.toContain("preview_project_shift");
|
||||
});
|
||||
|
||||
it("exposes self-service timeline tools to authenticated users without advanced assistant access", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
const viewerNames = getToolNames([], SystemRole.VIEWER);
|
||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||
|
||||
expect(userNames).toContain("get_my_timeline_entries_view");
|
||||
expect(userNames).toContain("get_my_timeline_holiday_overlays");
|
||||
expect(viewerNames).toContain("get_my_timeline_entries_view");
|
||||
expect(viewerNames).toContain("get_my_timeline_holiday_overlays");
|
||||
expect(controllerNames).toContain("get_my_timeline_entries_view");
|
||||
expect(controllerNames).toContain("get_my_timeline_holiday_overlays");
|
||||
});
|
||||
|
||||
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 estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
|
||||
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
|
||||
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
|
||||
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
|
||||
|
||||
expect(managerProjectNames).toContain("create_estimate");
|
||||
expect(managerProjectNames).toContain("clone_estimate");
|
||||
expect(managerProjectNames).toContain("update_estimate_draft");
|
||||
expect(managerProjectNames).toContain("submit_estimate_version");
|
||||
expect(managerProjectNames).toContain("approve_estimate_version");
|
||||
expect(managerProjectNames).toContain("create_estimate_revision");
|
||||
expect(managerProjectNames).toContain("create_estimate_export");
|
||||
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
|
||||
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
|
||||
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
|
||||
expect(managerAllocationNames).not.toContain("create_estimate");
|
||||
expect(userProjectNames).not.toContain("create_estimate");
|
||||
expect(userProjectNames).not.toContain("clone_estimate");
|
||||
expect(userProjectNames).not.toContain("update_estimate_draft");
|
||||
expect(userProjectNames).not.toContain("submit_estimate_version");
|
||||
expect(userProjectNames).not.toContain("approve_estimate_version");
|
||||
expect(userProjectNames).not.toContain("create_estimate_revision");
|
||||
expect(userProjectNames).not.toContain("create_estimate_export");
|
||||
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
|
||||
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
|
||||
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||
});
|
||||
|
||||
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
|
||||
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
|
||||
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("search_estimates");
|
||||
expect(controllerNames).toContain("get_estimate_detail");
|
||||
expect(controllerNames).toContain("list_estimate_versions");
|
||||
expect(controllerNames).toContain("get_estimate_version_snapshot");
|
||||
expect(controllerNames).toContain("get_estimate_weekly_phasing");
|
||||
expect(controllerNames).toContain("get_estimate_commercial_terms");
|
||||
expect(controllerWithoutCosts).toContain("search_estimates");
|
||||
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
|
||||
expect(controllerWithoutCosts).toContain("list_estimate_versions");
|
||||
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
|
||||
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
|
||||
expect(managerNames).toContain("search_estimates");
|
||||
expect(managerNames).toContain("get_estimate_detail");
|
||||
expect(managerNames).toContain("list_estimate_versions");
|
||||
expect(managerNames).toContain("get_estimate_version_snapshot");
|
||||
expect(managerNames).toContain("get_estimate_weekly_phasing");
|
||||
expect(managerNames).toContain("get_estimate_commercial_terms");
|
||||
expect(managerWithoutCosts).toContain("search_estimates");
|
||||
expect(managerWithoutCosts).toContain("list_estimate_versions");
|
||||
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||
expect(userNames).not.toContain("search_estimates");
|
||||
expect(userNames).not.toContain("get_estimate_detail");
|
||||
expect(userNames).not.toContain("list_estimate_versions");
|
||||
expect(userNames).not.toContain("get_estimate_version_snapshot");
|
||||
expect(userNames).not.toContain("get_estimate_weekly_phasing");
|
||||
expect(userNames).not.toContain("get_estimate_commercial_terms");
|
||||
});
|
||||
|
||||
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
|
||||
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
|
||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER);
|
||||
|
||||
expect(managerNames).toContain("import_csv_data");
|
||||
expect(controllerNames).toContain("export_resources_csv");
|
||||
expect(controllerNames).toContain("export_projects_csv");
|
||||
expect(adminNames).toContain("list_dispo_import_batches");
|
||||
expect(adminNames).toContain("get_dispo_import_batch");
|
||||
expect(adminNames).toContain("stage_dispo_import_batch");
|
||||
expect(adminNames).toContain("validate_dispo_import_batch");
|
||||
expect(adminNames).toContain("cancel_dispo_import_batch");
|
||||
expect(adminNames).toContain("list_dispo_staged_resources");
|
||||
expect(adminNames).toContain("list_dispo_staged_projects");
|
||||
expect(adminNames).toContain("list_dispo_staged_assignments");
|
||||
expect(adminNames).toContain("list_dispo_staged_vacations");
|
||||
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
|
||||
expect(adminNames).toContain("resolve_dispo_staged_record");
|
||||
expect(adminNames).toContain("commit_dispo_import_batch");
|
||||
expect(userNames).not.toContain("import_csv_data");
|
||||
expect(userNames).not.toContain("export_resources_csv");
|
||||
expect(userNames).not.toContain("export_projects_csv");
|
||||
expect(userNames).not.toContain("list_dispo_import_batches");
|
||||
expect(userNames).not.toContain("get_dispo_import_batch");
|
||||
expect(userNames).not.toContain("stage_dispo_import_batch");
|
||||
expect(userNames).not.toContain("validate_dispo_import_batch");
|
||||
expect(userNames).not.toContain("list_dispo_staged_resources");
|
||||
expect(userNames).not.toContain("commit_dispo_import_batch");
|
||||
});
|
||||
|
||||
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("get_system_settings");
|
||||
expect(adminNames).toContain("update_system_settings");
|
||||
expect(adminNames).toContain("clear_stored_runtime_secrets");
|
||||
expect(adminNames).toContain("test_ai_connection");
|
||||
expect(adminNames).toContain("test_smtp_connection");
|
||||
expect(adminNames).toContain("test_gemini_connection");
|
||||
expect(adminNames).toContain("list_system_role_configs");
|
||||
expect(adminNames).toContain("update_system_role_config");
|
||||
expect(adminNames).toContain("list_webhooks");
|
||||
expect(adminNames).toContain("get_webhook");
|
||||
expect(adminNames).toContain("create_webhook");
|
||||
expect(adminNames).toContain("update_webhook");
|
||||
expect(adminNames).toContain("delete_webhook");
|
||||
expect(adminNames).toContain("test_webhook");
|
||||
expect(adminNames).toContain("get_ai_configured");
|
||||
|
||||
expect(managerNames).not.toContain("get_system_settings");
|
||||
expect(managerNames).not.toContain("update_system_settings");
|
||||
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(managerNames).not.toContain("test_ai_connection");
|
||||
expect(managerNames).not.toContain("get_ai_configured");
|
||||
expect(managerNames).not.toContain("list_system_role_configs");
|
||||
expect(managerNames).not.toContain("update_system_role_config");
|
||||
expect(managerNames).not.toContain("list_webhooks");
|
||||
expect(managerNames).not.toContain("create_webhook");
|
||||
|
||||
expect(userNames).not.toContain("get_system_settings");
|
||||
expect(userNames).not.toContain("update_system_settings");
|
||||
expect(userNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(userNames).not.toContain("test_ai_connection");
|
||||
expect(userNames).not.toContain("get_ai_configured");
|
||||
expect(userNames).not.toContain("list_system_role_configs");
|
||||
expect(userNames).not.toContain("update_system_role_config");
|
||||
expect(userNames).not.toContain("list_webhooks");
|
||||
expect(userNames).not.toContain("create_webhook");
|
||||
});
|
||||
|
||||
it("keeps client deletion admin-only while still allowing manager client maintenance", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("create_client");
|
||||
expect(adminNames).toContain("update_client");
|
||||
expect(adminNames).toContain("delete_client");
|
||||
expect(managerNames).toContain("create_client");
|
||||
expect(managerNames).toContain("update_client");
|
||||
expect(managerNames).not.toContain("delete_client");
|
||||
expect(userNames).not.toContain("create_client");
|
||||
expect(userNames).not.toContain("update_client");
|
||||
expect(userNames).not.toContain("delete_client");
|
||||
});
|
||||
|
||||
it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
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).not.toContain("list_holiday_calendars");
|
||||
expect(managerNames).not.toContain("get_holiday_calendar");
|
||||
expect(managerNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(userNames).not.toContain("list_holiday_calendars");
|
||||
expect(userNames).not.toContain("get_holiday_calendar");
|
||||
expect(userNames).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);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
|
||||
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_countries");
|
||||
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).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");
|
||||
expect(userNames).not.toContain("search_resources");
|
||||
expect(userNames).not.toContain("get_country");
|
||||
expect(userNames).not.toContain("list_org_units");
|
||||
expect(userWithResourceOverview).toContain("search_resources");
|
||||
expect(userWithResourceOverview).toContain("get_country");
|
||||
expect(userWithResourceOverview).toContain("list_org_units");
|
||||
expect(userWithManagedResources).toContain("search_resources");
|
||||
expect(userWithManagedResources).toContain("get_country");
|
||||
expect(userWithManagedResources).toContain("list_org_units");
|
||||
});
|
||||
|
||||
it("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => {
|
||||
const toolAccess = new Map(
|
||||
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]),
|
||||
);
|
||||
|
||||
expect(toolAccess.get("run_report")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("simulate_scenario")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("detect_anomalies")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("get_insights_summary")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("lookup_rate")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("list_rate_cards")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
});
|
||||
expect(toolAccess.get("resolve_rate")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
});
|
||||
expect(toolAccess.get("import_csv_data")).toEqual({
|
||||
requiredPermissions: [PermissionKey.IMPORT_DATA],
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
});
|
||||
});
|
||||
|
||||
it("aligns assistant tool visibility with router role and permission rules", () => {
|
||||
const managerWithRolePermission = getToolNames(
|
||||
[PermissionKey.MANAGE_ROLES],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
|
||||
|
||||
expect(managerWithRolePermission).toContain("create_role");
|
||||
expect(managerWithRolePermission).toContain("update_role");
|
||||
expect(managerWithRolePermission).toContain("delete_role");
|
||||
expect(managerWithRolePermission).toContain("create_client");
|
||||
expect(managerWithRolePermission).toContain("update_client");
|
||||
expect(managerWithRolePermission).not.toContain("create_org_unit");
|
||||
expect(managerWithRolePermission).not.toContain("update_org_unit");
|
||||
|
||||
expect(managerWithoutRolePermission).not.toContain("create_role");
|
||||
expect(managerWithoutRolePermission).not.toContain("update_role");
|
||||
expect(managerWithoutRolePermission).not.toContain("delete_role");
|
||||
expect(managerWithoutRolePermission).toContain("create_client");
|
||||
expect(managerWithoutRolePermission).toContain("update_client");
|
||||
|
||||
const adminWithRolePermission = getToolNames(
|
||||
[PermissionKey.MANAGE_ROLES],
|
||||
SystemRole.ADMIN,
|
||||
);
|
||||
expect(adminWithRolePermission).toContain("create_org_unit");
|
||||
expect(adminWithRolePermission).toContain("update_org_unit");
|
||||
|
||||
const standardUserTools = getToolNames([], SystemRole.USER);
|
||||
expect(standardUserTools).toContain("get_vacation_balance");
|
||||
expect(standardUserTools).toContain("create_vacation");
|
||||
expect(standardUserTools).toContain("cancel_vacation");
|
||||
expect(standardUserTools).not.toContain("approve_vacation");
|
||||
expect(standardUserTools).not.toContain("reject_vacation");
|
||||
expect(standardUserTools).not.toContain("set_entitlement");
|
||||
|
||||
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
|
||||
expect(managerVacationTools).toContain("approve_vacation");
|
||||
expect(managerVacationTools).toContain("reject_vacation");
|
||||
expect(managerVacationTools).toContain("set_entitlement");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readToolError, readToolSuccessMessage } from "../router/assistant-tool-results.js";
|
||||
|
||||
describe("assistant tool results", () => {
|
||||
it("reads structured tool errors from result data or JSON content", () => {
|
||||
expect(readToolError({
|
||||
content: "",
|
||||
data: { error: "Forbidden" },
|
||||
})).toBe("Forbidden");
|
||||
|
||||
expect(readToolError({
|
||||
content: JSON.stringify({ error: "Validation failed" }),
|
||||
})).toBe("Validation failed");
|
||||
|
||||
expect(readToolError({
|
||||
content: "plain text",
|
||||
data: { ok: true },
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers structured success messages and falls back to plain content", () => {
|
||||
expect(readToolSuccessMessage({
|
||||
content: "",
|
||||
data: { message: "Created project Apollo" },
|
||||
})).toBe("Created project Apollo");
|
||||
|
||||
expect(readToolSuccessMessage({
|
||||
content: JSON.stringify({ description: "Updated allocation" }),
|
||||
})).toBe("Updated allocation");
|
||||
|
||||
expect(readToolSuccessMessage({
|
||||
content: "Fallback tool response",
|
||||
})).toBe("Fallback tool response");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js";
|
||||
import { selectAssistantToolsForRequest } from "../router/assistant-tool-selection.js";
|
||||
|
||||
function getSelectedToolNames(
|
||||
permissions: PermissionKeyValue[],
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>,
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
pageContext?: string,
|
||||
) {
|
||||
return selectAssistantToolsForRequest(
|
||||
getAvailableAssistantTools(new Set(permissions), userRole),
|
||||
messages,
|
||||
pageContext,
|
||||
).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
describe("assistant tool selection", () => {
|
||||
it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => {
|
||||
const allPermissions = Object.values(PermissionKey);
|
||||
const selectedNames = getSelectedToolNames(
|
||||
allPermissions,
|
||||
[{ role: "user", content: "Bitte gib mir einen Überblick über das System." }],
|
||||
SystemRole.ADMIN,
|
||||
);
|
||||
|
||||
expect(selectedNames.length).toBeLessThanOrEqual(128);
|
||||
expect(selectedNames).toContain("get_current_user");
|
||||
expect(selectedNames).toContain("search_resources");
|
||||
expect(selectedNames).toContain("search_projects");
|
||||
});
|
||||
|
||||
it("prioritizes holiday and resource tools for German holiday questions", () => {
|
||||
const allPermissions = Object.values(PermissionKey);
|
||||
const selectedNames = getSelectedToolNames(
|
||||
allPermissions,
|
||||
[{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }],
|
||||
SystemRole.ADMIN,
|
||||
);
|
||||
|
||||
expect(selectedNames.length).toBeLessThanOrEqual(128);
|
||||
expect(selectedNames).toContain("search_resources");
|
||||
expect(selectedNames).toContain("get_resource");
|
||||
expect(selectedNames).toContain("get_resource_holidays");
|
||||
expect(selectedNames).toContain("list_holidays_by_region");
|
||||
expect(selectedNames).toContain("list_holiday_calendars");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user