diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-assignable-users.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-assignable-users.test.ts new file mode 100644 index 0000000..7f140da --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-assignable-users.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin tool list_assignable_users", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists assignable users through the real user router path for manager users", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "user_1", name: "Alice", email: "alice@example.com" }, + ]); + const ctx = createToolContext({ + user: { + findMany, + }, + }, SystemRole.MANAGER); + + const result = await executeTool( + "list_assignable_users", + JSON.stringify({}), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual([ + { id: "user_1", name: "Alice", email: "alice@example.com" }, + ]); + expect(findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + email: true, + }, + orderBy: { name: "asc" }, + }); + }); + + it("rejects assignable user listing for regular users through the backing router", async () => { + const ctx = createToolContext({ + user: { + findMany: vi.fn(), + }, + }, SystemRole.USER); + + const result = await executeTool( + "list_assignable_users", + JSON.stringify({}), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "You do not have permission to perform this action.", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-inventory-read.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-inventory-read.test.ts new file mode 100644 index 0000000..a094e47 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-inventory-read.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin inventory read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the active user count for admin users", async () => { + const count = vi.fn().mockResolvedValue(4); + const ctx = createToolContext({ + user: { + count, + }, + }, SystemRole.ADMIN); + + const result = await executeTool( + "get_active_user_count", + JSON.stringify({}), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ count: 4 }); + expect(count).toHaveBeenCalledWith({ + where: { + lastActiveAt: { + gte: expect.any(Date), + }, + }, + }); + }); + + it("lists users only for admins through the real user router", async () => { + const db = { + user: { + findMany: vi.fn().mockResolvedValue([ + { id: "user_1", name: "Alice", email: "alice@example.com" }, + { id: "user_2", name: "Bob", email: "bob@example.com" }, + ]), + }, + }; + const adminCtx = createToolContext(db, SystemRole.ADMIN); + const managerCtx = createToolContext({}, SystemRole.MANAGER); + + const adminResult = await executeTool( + "list_users", + JSON.stringify({ limit: 1 }), + adminCtx, + ); + const deniedResult = await executeTool("list_users", "{}", managerCtx); + + expect(db.user.findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + email: true, + systemRole: true, + createdAt: true, + lastLoginAt: true, + lastActiveAt: true, + permissionOverrides: true, + totpEnabled: true, + }, + orderBy: { name: "asc" }, + }); + expect(JSON.parse(adminResult.content)).toEqual([ + expect.objectContaining({ id: "user_1", name: "Alice" }), + ]); + expect(JSON.parse(deniedResult.content)).toEqual( + expect.objectContaining({ + error: "You do not have permission to perform this action.", + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-name-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-name-errors.test.ts new file mode 100644 index 0000000..fa901c5 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-name-errors.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin name update errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when renaming a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "update_user_name", + JSON.stringify({ id: "user_missing", name: "Miles Morales" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when renaming a user without a name", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "update_user_name", + JSON.stringify({ id: "user_1", name: "" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Name is required.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when renaming a user with a name that is too long", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "update_user_name", + JSON.stringify({ id: "user_1", name: "x".repeat(201) }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Name must be at most 200 characters.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts new file mode 100644 index 0000000..2f7040a --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin password and role errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when resetting the password of a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "set_user_password", + JSON.stringify({ userId: "user_missing", password: "secret123" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when resetting a password that is too short", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "set_user_password", + JSON.stringify({ userId: "user_1", password: "short" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Password must be at least 8 characters.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when updating the role of a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "update_user_role", + JSON.stringify({ id: "user_missing", systemRole: SystemRole.MANAGER }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-resource-auto-linking.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-resource-auto-linking.test.ts new file mode 100644 index 0000000..b9c7c1a --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-resource-auto-linking.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin tools resource linking", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("auto-links users by email for admin users and returns an invalidation action", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "user_1", email: "alice@example.com" }, + { id: "user_2", email: "bob@example.com" }, + ]); + const findFirst = vi.fn().mockResolvedValueOnce({ id: "resource_1" }).mockResolvedValueOnce(null); + const update = vi.fn().mockResolvedValue(undefined); + const ctx = createToolContext( + { + user: { + findMany, + }, + resource: { + findFirst, + update, + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool("auto_link_users_by_email", JSON.stringify({}), ctx); + + expect(result.action).toEqual({ + type: "invalidate", + scope: ["user", "resource"], + }); + expect(result.data).toMatchObject({ + success: true, + linked: 1, + checked: 2, + message: "Auto-linked 1 user(s) by email.", + }); + expect(findMany).toHaveBeenCalledWith({ + where: { resource: null }, + select: { id: true, email: true }, + }); + expect(findFirst).toHaveBeenNthCalledWith(1, { + where: { email: "alice@example.com", userId: null }, + select: { id: true }, + }); + expect(findFirst).toHaveBeenNthCalledWith(2, { + where: { email: "bob@example.com", userId: null }, + select: { id: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "resource_1" }, + data: { userId: "user_1" }, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-conflict-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-conflict-errors.test.ts new file mode 100644 index 0000000..bb1138d --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-conflict-errors.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin resource linking conflict errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when linking a resource that is already linked to another user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ id: "resource_1", userId: "user_2" }), + updateMany: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "resource_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource is already linked to another user.", + }); + }); + + it("returns a stable retry error when the resource link changes during update", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue({ id: "resource_1", userId: null }), + updateMany: vi.fn().mockResolvedValueOnce({ count: 0 }).mockResolvedValueOnce({ count: 0 }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "resource_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource link changed during update. Please retry.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-not-found-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-not-found-errors.test.ts new file mode 100644 index 0000000..9ce6c93 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-not-found-errors.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin resource linking not-found errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when linking a missing user to a resource", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_missing", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); + + it("returns a stable error when linking a user to a missing resource", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); + + it("returns a stable error when linking a user to a resource that disappears during persistence", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + resource: { + findUnique: vi.fn().mockResolvedValueOnce({ id: "res_1", userId: null }).mockResolvedValueOnce(null), + updateMany: vi.fn().mockResolvedValueOnce({ count: 0 }).mockResolvedValueOnce({ count: 0 }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Resource not found with the given criteria.", + }); + }); + + it("returns a stable error when linking a user after the user disappears during persistence", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValueOnce({ id: "user_1" }).mockResolvedValueOnce(null), + }, + resource: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ id: "res_1", userId: null }) + .mockResolvedValueOnce({ id: "res_1", userId: null }), + updateMany: vi.fn().mockResolvedValueOnce({ count: 0 }).mockResolvedValueOnce({ count: 0 }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); + + it("returns a stable error when linking a user after the user disappears and prisma reports an array target", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValueOnce({ id: "user_1" }).mockResolvedValueOnce(null), + }, + resource: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ id: "res_1", userId: null }) + .mockResolvedValueOnce({ id: "res_1", userId: null }), + updateMany: vi.fn().mockResolvedValueOnce({ count: 0 }).mockResolvedValueOnce({ count: 0 }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "res_1" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-success.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-success.test.ts new file mode 100644 index 0000000..8d6e910 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-resource-linking-success.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin tools resource linking", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("links a user to a resource for admin users and returns an invalidation action", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); + const updateMany = vi.fn().mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 1 }); + const ctx = createToolContext( + { + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: "resource_1" }), + ctx, + ); + + expect(result.action).toEqual({ + type: "invalidate", + scope: ["user", "resource"], + }); + expect(result.data).toEqual({ + success: true, + message: "Linked user to resource.", + }); + expect(userFindUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { id: true }, + }); + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "resource_1" }, + select: { id: true, userId: true }, + }); + expect(updateMany).toHaveBeenNthCalledWith(1, { + where: { + userId: "user_1", + NOT: { id: "resource_1" }, + }, + data: { userId: null }, + }); + expect(updateMany).toHaveBeenNthCalledWith(2, { + where: { + id: "resource_1", + OR: [{ userId: null }, { userId: "user_1" }], + }, + data: { userId: "user_1" }, + }); + }); + + it("unlinks a user resource for admin users and returns an invalidation action", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const ctx = createToolContext( + { + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: vi.fn(), + updateMany, + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "link_user_resource", + JSON.stringify({ userId: "user_1", resourceId: null }), + ctx, + ); + + expect(result.action).toEqual({ + type: "invalidate", + scope: ["user", "resource"], + }); + expect(result.data).toEqual({ + success: true, + message: "Unlinked user resource.", + }); + expect(userFindUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { id: true }, + }); + expect(updateMany).toHaveBeenCalledWith({ + where: { userId: "user_1" }, + data: { userId: null }, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-user-admin-test-helpers.ts new file mode 100644 index 0000000..88eaae4 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-test-helpers.ts @@ -0,0 +1,25 @@ +import { SystemRole } from "@capakraken/shared"; + +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + userRole: SystemRole, +): ToolContext { + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(), + session: { + user: { email: "user@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts new file mode 100644 index 0000000..a1badf8 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin tools user create errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when creating a user with a duplicate email", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_existing", + email: "peter.parker@example.com", + name: "Peter Parker", + }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "create_user", + JSON.stringify({ + email: "peter.parker@example.com", + name: "Peter Parker", + password: "secret123", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User with this email already exists.", + })); + }); + + it("returns a stable error when creating a user without a name", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "create_user", + JSON.stringify({ + email: "miles.morales@example.com", + name: "", + password: "secret123", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Name is required.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns a stable error when creating a user with a password that is too short", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn(), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "create_user", + JSON.stringify({ + email: "miles.morales@example.com", + name: "Miles Morales", + password: "short", + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "Password must be at least 8 characters.", + })); + expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-user-permissions-totp.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-user-permissions-totp.test.ts new file mode 100644 index 0000000..b638a5c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-admin-user-permissions-totp.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-admin-test-helpers.js"; + +describe("assistant user admin tools permissions and totp errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable error when setting permissions for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "set_user_permissions", + JSON.stringify({ userId: "user_missing", overrides: { granted: ["manageProjects"] } }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when resetting permissions for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "reset_user_permissions", + JSON.stringify({ userId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when reading effective permissions for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "get_effective_user_permissions", + JSON.stringify({ userId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); + + it("returns a stable error when disabling TOTP for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "disable_user_totp", + JSON.stringify({ userId: "user_missing" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ + error: "User not found with the given criteria.", + })); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-auth-guard.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-auth-guard.test.ts new file mode 100644 index 0000000..86b7a32 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-auth-guard.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool, type ToolContext } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-self-service-test-helpers.js"; + +describe("assistant user self-service auth guard tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a stable assistant error when authenticated assistant context is missing for a self-service read tool", async () => { + const ctx = { + ...createToolContext({}, SystemRole.ADMIN), + session: null, + dbUser: null, + } as unknown as ToolContext; + + const result = await executeTool("get_current_user", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ + error: "Authenticated assistant context is required for this tool.", + }); + }); + + it("returns a stable assistant error when authenticated assistant context is missing for a self-service mutation tool", async () => { + const ctx = { + ...createToolContext({}, SystemRole.ADMIN), + session: null, + dbUser: null, + } as unknown as ToolContext; + + const result = await executeTool( + "save_dashboard_layout", + JSON.stringify({ + layout: { + version: 2, + gridCols: 12, + widgets: [], + }, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "Authenticated assistant context is required for this tool.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-column-preferences.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-column-preferences.test.ts new file mode 100644 index 0000000..7cb1e97 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-column-preferences.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-self-service-test-helpers.js"; + +describe("assistant user self-service column preferences tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads column preferences through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + columnPreferences: { + resources: { + visible: ["name", "role"], + sort: { field: "name", dir: "asc" }, + }, + }, + }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool("get_column_preferences", "{}", ctx); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { columnPreferences: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + resources: { + visible: ["name", "role"], + sort: { field: "name", dir: "asc" }, + }, + }); + }); + + it("updates column preferences through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + columnPreferences: { + resources: { + visible: ["name", "role"], + sort: { field: "name", dir: "asc" }, + rowOrder: ["name", "role", "email"], + }, + }, + }), + update: vi.fn().mockResolvedValue({ id: "user_1" }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool( + "set_column_preferences", + JSON.stringify({ + view: "resources", + visible: ["name", "email"], + }), + ctx, + ); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user_1" }, + data: { + columnPreferences: { + resources: { + visible: ["name", "email"], + sort: { field: "name", dir: "asc" }, + rowOrder: ["name", "role", "email"], + }, + }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + success: true, + ok: true, + message: "Updated column preferences for resources.", + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["user"], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-dashboard-layout.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-dashboard-layout.test.ts new file mode 100644 index 0000000..d2ed82a --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-dashboard-layout.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-self-service-test-helpers.js"; + +describe("assistant user self-service dashboard layout tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads dashboard layout through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + dashboardLayout: { + widgets: [ + { id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }, + ], + }, + updatedAt: new Date("2026-03-30T18:00:00.000Z"), + }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool("get_dashboard_layout", "{}", ctx); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { dashboardLayout: true, updatedAt: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + layout: { + version: 2, + gridCols: 12, + widgets: [], + }, + updatedAt: "2026-03-30T18:00:00.000Z", + }); + }); + + it("saves dashboard layout through the real user router path", async () => { + const db = { + user: { + update: vi.fn().mockResolvedValue({ + updatedAt: new Date("2026-03-30T19:00:00.000Z"), + }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + const layout = { + version: 2, + gridCols: 12, + widgets: [], + }; + + const result = await executeTool( + "save_dashboard_layout", + JSON.stringify({ layout }), + ctx, + ); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user_1" }, + data: { dashboardLayout: layout }, + select: { updatedAt: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + success: true, + updatedAt: "2026-03-30T19:00:00.000Z", + message: "Saved dashboard layout.", + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["dashboard"], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-favorite-projects.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-favorite-projects.test.ts new file mode 100644 index 0000000..8a88f17 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-favorite-projects.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-self-service-test-helpers.js"; + +describe("assistant user self-service favorite project tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads favorite project ids through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + favoriteProjectIds: ["project_1", "project_2"], + }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool("get_favorite_project_ids", "{}", ctx); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { favoriteProjectIds: true }, + }); + expect(JSON.parse(result.content)).toEqual(["project_1", "project_2"]); + }); + + it("adds a project to favorites through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + favoriteProjectIds: ["project_1"], + }), + update: vi.fn().mockResolvedValue({}), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool( + "toggle_favorite_project", + JSON.stringify({ projectId: "project_2" }), + ctx, + ); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user_1" }, + data: { favoriteProjectIds: ["project_1", "project_2"] }, + }); + expect(JSON.parse(result.content)).toEqual({ + success: true, + favoriteProjectIds: ["project_1", "project_2"], + added: true, + message: "Added project to favorites.", + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["project"], + }); + }); + + it("removes a project from favorites through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + favoriteProjectIds: ["project_1", "project_2"], + }), + update: vi.fn().mockResolvedValue({}), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool( + "toggle_favorite_project", + JSON.stringify({ projectId: "project_2" }), + ctx, + ); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user_1" }, + data: { favoriteProjectIds: ["project_1"] }, + }); + expect(JSON.parse(result.content)).toEqual({ + success: true, + favoriteProjectIds: ["project_1"], + added: false, + message: "Removed project from favorites.", + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["project"], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts new file mode 100644 index 0000000..5158d0c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, + totpValidateMock, +} from "./assistant-tools-user-self-service-mfa-test-helpers.js"; + +describe("assistant user self-service MFA tools - enable flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + totpValidateMock.mockReset(); + }); + + it("generates a TOTP secret through the real user router path", async () => { + const db = { + user: { + update: vi.fn().mockResolvedValue({}), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool("generate_totp_secret", "{}", ctx); + + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user_1" }, + data: { totpSecret: "MOCKSECRET" }, + }); + expect(JSON.parse(result.content)).toEqual({ + success: true, + secret: "MOCKSECRET", + uri: "otpauth://mock", + message: "Generated a new MFA TOTP secret.", + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["user"], + }); + }); + + it("enables TOTP through the real user router path when the token is valid", async () => { + totpValidateMock.mockReturnValue(0); + + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: false, + }), + update: vi.fn().mockResolvedValue({}), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit_1" }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, + }); + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user_1" }, + data: { totpEnabled: true }, + }); + expect(db.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entityType: "User", + entityId: "user_1", + action: "UPDATE", + userId: "user_1", + source: "ui", + entityName: "Assistant User (assistant@example.com)", + summary: "Enabled TOTP MFA", + }), + }); + expect(JSON.parse(result.content)).toEqual({ + success: true, + enabled: true, + message: "Enabled MFA TOTP.", + }); + expect(result.action).toEqual({ + type: "invalidate", + scope: ["user"], + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-errors.test.ts new file mode 100644 index 0000000..05b6b63 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-errors.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, + totpValidateMock, +} from "./assistant-tools-user-self-service-mfa-test-helpers.js"; + +describe("assistant user self-service MFA tools - errors", () => { + beforeEach(() => { + vi.clearAllMocks(); + totpValidateMock.mockReset(); + }); + + it("returns a stable error when enabling TOTP without a generated secret", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: null, + totpEnabled: false, + }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "No TOTP secret generated. Call generate_totp_secret first.", + }); + }); + + it("returns a stable error when enabling TOTP for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); + + it("returns a stable error when enabling TOTP that is already enabled", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: true, + }), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + error: "TOTP is already enabled.", + }); + }); + + it("returns a stable error when a provided TOTP token is invalid", async () => { + totpValidateMock.mockReturnValue(null); + + const update = vi.fn(); + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: false, + }), + update, + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool( + "verify_and_enable_totp", + JSON.stringify({ token: "123456" }), + ctx, + ); + + expect(update).not.toHaveBeenCalled(); + expect(JSON.parse(result.content)).toEqual({ + error: "Invalid TOTP token.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts new file mode 100644 index 0000000..13751cc --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-status.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { + createToolContext, + executeTool, + totpValidateMock, +} from "./assistant-tools-user-self-service-mfa-test-helpers.js"; + +describe("assistant user self-service MFA tools - status", () => { + beforeEach(() => { + vi.clearAllMocks(); + totpValidateMock.mockReset(); + }); + + it("returns MFA status through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + totpEnabled: true, + }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool("get_mfa_status", "{}", ctx); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { totpEnabled: true }, + }); + expect(JSON.parse(result.content)).toEqual({ + totpEnabled: true, + }); + }); + + it("returns a stable error when reading MFA status for a missing user", async () => { + const ctx = createToolContext( + { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + SystemRole.ADMIN, + ); + + const result = await executeTool("get_mfa_status", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-test-helpers.ts new file mode 100644 index 0000000..4df8f8e --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-test-helpers.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; + +const hoistedMocks = vi.hoisted(() => ({ + totpValidateMock: vi.fn(), +})); + +export const totpValidateMock = hoistedMocks.totpValidateMock; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("otpauth", () => { + class Secret { + base32: string; + + constructor() { + this.base32 = "MOCKSECRET"; + } + + static fromBase32(value: string) { + return value; + } + } + + class TOTP { + validate(args: { token: string; window: number }) { + return totpValidateMock(args); + } + + toString() { + return "otpauth://mock"; + } + } + + return { Secret, TOTP }; +}); + +import { executeTool as executeAssistantTool } from "../router/assistant-tools.js"; + +export { createToolContext } from "./assistant-tools-user-self-service-test-helpers.js"; + +export const executeTool = executeAssistantTool; diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-profile.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-profile.test.ts new file mode 100644 index 0000000..db47b1e --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-profile.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-user-self-service-test-helpers.js"; + +describe("assistant user self-service profile tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads the current user profile through the real user router path", async () => { + const db = { + user: { + findUnique: vi.fn().mockResolvedValue({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: new Date("2026-03-28T10:00:00.000Z"), + }), + }, + }; + const ctx = createToolContext(db, SystemRole.ADMIN); + + const result = await executeTool("get_current_user", "{}", ctx); + + expect(db.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { + id: true, + name: true, + email: true, + systemRole: true, + permissionOverrides: true, + createdAt: true, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + id: "user_1", + name: "Assistant User", + email: "assistant@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: "2026-03-28T10:00:00.000Z", + }); + }); + + it("returns a stable error when reading the current user profile for a missing user", async () => { + const ctx = createToolContext({ + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, SystemRole.ADMIN); + + const result = await executeTool("get_current_user", "{}", ctx); + + expect(JSON.parse(result.content)).toEqual({ + error: "User not found with the given criteria.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-test-helpers.ts new file mode 100644 index 0000000..12f0e05 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-test-helpers.ts @@ -0,0 +1,25 @@ +import { SystemRole } from "@capakraken/shared"; + +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + userRole: SystemRole = SystemRole.USER, +): ToolContext { + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(), + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +} diff --git a/packages/api/src/__tests__/user-router-auth.test.ts b/packages/api/src/__tests__/user-router-auth.test.ts index f6e0e3b..8b2ab7a 100644 --- a/packages/api/src/__tests__/user-router-auth.test.ts +++ b/packages/api/src/__tests__/user-router-auth.test.ts @@ -85,6 +85,82 @@ describe("user router authorization", () => { expect(update).not.toHaveBeenCalled(); }); + it("requires authentication for dashboard layout saves", async () => { + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + update, + }, + }, { session: false })); + + await expect(caller.saveDashboardLayout({ + layout: { version: 2, gridCols: 12, widgets: [] }, + })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(update).not.toHaveBeenCalled(); + }); + + it("requires authentication for favorite project reads", async () => { + const findUnique = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + }, + }, { session: false })); + + await expect(caller.getFavoriteProjectIds()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("requires authentication for column preference reads and writes", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + update, + }, + }, { session: false })); + + await expect(caller.getColumnPreferences()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + await expect(caller.setColumnPreferences({ + view: "resources", + visible: ["name"], + })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("requires authentication for MFA status lookups", async () => { + const findUniqueOrThrow = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUniqueOrThrow, + }, + }, { session: false })); + + await expect(caller.getMfaStatus()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(findUniqueOrThrow).not.toHaveBeenCalled(); + }); + it("forbids regular users from listing assignable users", async () => { const findMany = vi.fn(); const caller = createCaller(createContext({ @@ -140,6 +216,49 @@ describe("user router authorization", () => { expect(findUnique).not.toHaveBeenCalled(); }); + it("forbids non-admin users from setting explicit permission overrides", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + update, + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.setPermissions({ + userId: "user_2", + overrides: { + granted: ["manageProjects"], + }, + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("forbids non-admin users from resetting permission overrides", async () => { + const findUnique = vi.fn(); + const update = vi.fn(); + const caller = createCaller(createContext({ + user: { + findUnique, + update, + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.resetPermissions({ userId: "user_2" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + it("forbids non-admin users from disabling TOTP for other users", async () => { const findUnique = vi.fn(); const caller = createCaller(createContext({ @@ -186,15 +305,39 @@ describe("user router authorization", () => { expect(update).not.toHaveBeenCalled(); }); + it("forbids non-admin users from auto-linking users by email", async () => { + const userFindMany = vi.fn(); + const resourceFindFirst = vi.fn(); + const resourceUpdate = vi.fn(); + const caller = createCaller(createContext({ + user: { + findMany: userFindMany, + }, + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + }, { role: SystemRole.MANAGER })); + + await expect(caller.autoLinkAllByEmail()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(userFindMany).not.toHaveBeenCalled(); + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(resourceUpdate).not.toHaveBeenCalled(); + }); + it("keeps TOTP verification public for the login flow", async () => { - const findUniqueOrThrow = vi.fn().mockResolvedValue({ + const findUnique = vi.fn().mockResolvedValue({ id: "user_1", totpEnabled: false, totpSecret: null, }); const caller = createCaller(createContext({ user: { - findUniqueOrThrow, + findUnique, }, }, { session: false })); @@ -203,7 +346,7 @@ describe("user router authorization", () => { message: "TOTP is not enabled for this user.", }); - expect(findUniqueOrThrow).toHaveBeenCalledWith({ + expect(findUnique).toHaveBeenCalledWith({ where: { id: "user_1" }, select: { id: true, totpSecret: true, totpEnabled: true }, }); diff --git a/packages/api/src/__tests__/user-router.test.ts b/packages/api/src/__tests__/user-router.test.ts index a222af8..ead57c2 100644 --- a/packages/api/src/__tests__/user-router.test.ts +++ b/packages/api/src/__tests__/user-router.test.ts @@ -1,8 +1,51 @@ -import { SystemRole } from "@capakraken/shared"; +import { Prisma } from "@capakraken/db"; +import { resolvePermissions, SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { userRouter } from "../router/user.js"; import { createCallerFactory } from "../trpc.js"; +const { totpValidateMock } = vi.hoisted(() => ({ + totpValidateMock: vi.fn(), +})); + +const { argon2HashMock } = vi.hoisted(() => ({ + argon2HashMock: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@node-rs/argon2", () => ({ + hash: argon2HashMock, +})); + +vi.mock("otpauth", () => { + class Secret { + base32: string; + + constructor() { + this.base32 = "MOCKSECRET"; + } + + static fromBase32(value: string) { + return value; + } + } + + class TOTP { + validate(args: { token: string; window: number }) { + return totpValidateMock(args); + } + + toString() { + return "otpauth://mock"; + } + } + + return { Secret, TOTP }; +}); + const createCaller = createCallerFactory(userRouter); function createAdminCaller(db: Record) { @@ -59,7 +102,6 @@ describe("user.linkResource", () => { const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); const resourceFindUnique = vi.fn().mockResolvedValue(null); const updateMany = vi.fn(); - const update = vi.fn(); const caller = createAdminCaller({ user: { findUnique: userFindUnique, @@ -67,7 +109,6 @@ describe("user.linkResource", () => { resource: { findUnique: resourceFindUnique, updateMany, - update, }, }); @@ -81,17 +122,42 @@ describe("user.linkResource", () => { expect(resourceFindUnique).toHaveBeenCalledWith({ where: { id: "missing_resource" }, - select: { id: true }, + select: { id: true, userId: true }, }); expect(updateMany).not.toHaveBeenCalled(); - expect(update).not.toHaveBeenCalled(); }); - it("unlinks existing assignments before linking the requested resource", async () => { + it("returns CONFLICT when the requested resource is already linked to another user", async () => { const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); - const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1" }); - const updateMany = vi.fn().mockResolvedValue({ count: 1 }); - const update = vi.fn().mockResolvedValue({ id: "resource_1", userId: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: "user_2" }); + const updateMany = vi.fn(); + const caller = createAdminCaller({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + }, + }); + + await expect(caller.linkResource({ + userId: "user_1", + resourceId: "resource_1", + })).rejects.toMatchObject({ + code: "CONFLICT", + message: "Resource is already linked to another user", + }); + + expect(updateMany).not.toHaveBeenCalled(); + }); + + it("unlinks existing assignments before linking the requested resource", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); + const updateMany = vi.fn() + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 1 }); const caller = createAdminCaller({ user: { findUnique: userFindUnique, @@ -99,7 +165,6 @@ describe("user.linkResource", () => { resource: { findUnique: resourceFindUnique, updateMany, - update, }, }); @@ -109,13 +174,850 @@ describe("user.linkResource", () => { }); expect(result).toEqual({ success: true }); + expect(updateMany).toHaveBeenNthCalledWith(1, { + where: { + userId: "user_1", + NOT: { id: "resource_1" }, + }, + data: { userId: null }, + }); + expect(updateMany).toHaveBeenNthCalledWith(2, { + where: { + id: "resource_1", + OR: [{ userId: null }, { userId: "user_1" }], + }, + data: { userId: "user_1" }, + }); + }); + + it("unlinks a user without checking or updating a replacement resource", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn(); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const caller = createAdminCaller({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + }, + }); + + const result = await caller.linkResource({ + userId: "user_1", + resourceId: null, + }); + + expect(result).toEqual({ success: true }); + expect(userFindUnique).toHaveBeenCalledWith({ + where: { id: "user_1" }, + select: { id: true }, + }); + expect(resourceFindUnique).not.toHaveBeenCalled(); expect(updateMany).toHaveBeenCalledWith({ where: { userId: "user_1" }, data: { userId: null }, }); - expect(update).toHaveBeenCalledWith({ - where: { id: "resource_1" }, - data: { userId: "user_1" }, + }); + + it("returns CONFLICT when the resource link changes between validation and update", async () => { + const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); + const updateMany = vi.fn() + .mockResolvedValueOnce({ count: 0 }) + .mockResolvedValueOnce({ count: 0 }); + const caller = createAdminCaller({ + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + }, + }); + + await expect(caller.linkResource({ + userId: "user_1", + resourceId: "resource_1", + })).rejects.toMatchObject({ + code: "CONFLICT", + message: "Resource link changed during update. Please retry.", + }); + }); +}); + +describe("user admin account management", () => { + it("counts users active within the last five minutes", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf()); + const count = vi.fn().mockResolvedValue(4); + const caller = createAdminCaller({ + user: { + count, + }, + }); + + const result = await caller.activeCount(); + + expect(result).toEqual({ count: 4 }); + expect(count).toHaveBeenCalledWith({ + where: { + lastActiveAt: { gte: new Date("2026-03-30T19:55:00.000Z") }, + }, + }); + + nowSpy.mockRestore(); + }); + + it("hashes and stores a replacement password for an existing user", async () => { + argon2HashMock.mockReset(); + argon2HashMock.mockResolvedValue("hashed-secret"); + + const findUnique = vi.fn().mockResolvedValue({ + id: "user_2", + name: "Alice", + email: "alice@example.com", + }); + const update = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.setPassword({ + userId: "user_2", + password: "secret123", + }); + + expect(result).toEqual({ success: true }); + expect(argon2HashMock).toHaveBeenCalledWith("secret123"); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_2" }, + data: { passwordHash: "hashed-secret" }, + }); + }); + + it("updates the selected user's display name", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user_2", + name: "Alice", + email: "alice@example.com", + }); + const update = vi.fn().mockResolvedValue({ + id: "user_2", + name: "Alice Updated", + email: "alice@example.com", + }); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.updateName({ + id: "user_2", + name: "Alice Updated", + }); + + expect(result).toEqual({ + id: "user_2", + name: "Alice Updated", + email: "alice@example.com", + }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_2" }, + select: { id: true, name: true, email: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_2" }, + data: { name: "Alice Updated" }, + select: { id: true, name: true, email: true }, + }); + }); +}); + +describe("user.autoLinkAllByEmail", () => { + it("links only users with an unclaimed resource that matches their email", async () => { + const userFindMany = vi.fn().mockResolvedValue([ + { id: "user_1", email: "alice@example.com" }, + { id: "user_2", email: "bob@example.com" }, + { id: "user_3", email: "carol@example.com" }, + ]); + const resourceFindFirst = vi + .fn() + .mockResolvedValueOnce({ id: "resource_1" }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "resource_3" }); + const resourceUpdate = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + user: { + findMany: userFindMany, + }, + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + }); + + const result = await caller.autoLinkAllByEmail(); + + expect(result).toEqual({ linked: 2, checked: 3 }); + expect(userFindMany).toHaveBeenCalledWith({ + where: { resource: null }, + select: { id: true, email: true }, + }); + expect(resourceFindFirst).toHaveBeenNthCalledWith(1, { + where: { email: "alice@example.com", userId: null }, + select: { id: true }, + }); + expect(resourceFindFirst).toHaveBeenNthCalledWith(2, { + where: { email: "bob@example.com", userId: null }, + select: { id: true }, + }); + expect(resourceFindFirst).toHaveBeenNthCalledWith(3, { + where: { email: "carol@example.com", userId: null }, + select: { id: true }, + }); + expect(resourceUpdate).toHaveBeenCalledTimes(2); + expect(resourceUpdate).toHaveBeenNthCalledWith(1, { + where: { id: "resource_1" }, + data: { userId: "user_1" }, + }); + expect(resourceUpdate).toHaveBeenNthCalledWith(2, { + where: { id: "resource_3" }, + data: { userId: "user_3" }, + }); + }); +}); + +describe("user permission overrides", () => { + it("stores explicit permission overrides for a user", async () => { + const before = { + id: "user_2", + name: "Alice", + email: "alice@example.com", + permissionOverrides: null, + }; + const updated = { + id: "user_2", + permissionOverrides: { + granted: ["manageProjects"], + denied: ["viewCosts"], + chapterIds: ["chapter_design"], + }, + }; + const findUnique = vi.fn().mockResolvedValue(before); + const update = vi.fn().mockResolvedValue(updated); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const overrides = { + granted: ["manageProjects"], + denied: ["viewCosts"], + chapterIds: ["chapter_design"], + }; + const result = await caller.setPermissions({ + userId: "user_2", + overrides, + }); + + expect(result).toEqual(updated); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_2" }, + select: { id: true, name: true, email: true, permissionOverrides: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_2" }, + data: { permissionOverrides: overrides }, + }); + }); + + it("resets permission overrides back to role defaults", async () => { + const before = { + id: "user_2", + name: "Alice", + email: "alice@example.com", + permissionOverrides: { + granted: ["manageProjects"], + denied: ["viewCosts"], + }, + }; + const updated = { + id: "user_2", + permissionOverrides: null, + }; + const findUnique = vi.fn().mockResolvedValue(before); + const update = vi.fn().mockResolvedValue(updated); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.resetPermissions({ userId: "user_2" }); + + expect(result).toEqual(updated); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_2" }, + select: { id: true, name: true, email: true, permissionOverrides: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_2" }, + data: { permissionOverrides: Prisma.DbNull }, + }); + }); + + it("returns resolved effective permissions together with stored overrides", async () => { + const overrides = { + granted: ["manageProjects"], + denied: ["viewCosts"], + chapterIds: ["chapter_design"], + }; + const findUnique = vi.fn().mockResolvedValue({ + systemRole: SystemRole.MANAGER, + permissionOverrides: overrides, + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.getEffectivePermissions({ userId: "user_2" }); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_2" }, + select: { systemRole: true, permissionOverrides: true }, + }); + expect(result).toEqual({ + systemRole: SystemRole.MANAGER, + effectivePermissions: Array.from(resolvePermissions(SystemRole.MANAGER, overrides)), + overrides, + }); + }); +}); + +describe("user profile and TOTP self-service", () => { + it("returns the authenticated user profile by db user id", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: new Date("2026-03-30T08:00:00.000Z"), + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.me(); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { + id: true, + name: true, + email: true, + systemRole: true, + permissionOverrides: true, + createdAt: true, + }, + }); + expect(result).toEqual({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: new Date("2026-03-30T08:00:00.000Z"), + }); + }); + + it("uses the db user id for self-service profile reads even when the session email is stale", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + createdAt: new Date("2026-03-30T08:00:00.000Z"), + }); + const caller = createCaller({ + session: { + user: { email: "stale@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: { + user: { + findUnique, + }, + } as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); + + await caller.me(); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { + id: true, + name: true, + email: true, + systemRole: true, + permissionOverrides: true, + createdAt: true, + }, + }); + }); + + it("stores a new TOTP secret for the current user and returns the enrollment URI", async () => { + const update = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + user: { + update, + }, + }); + + const result = await caller.generateTotpSecret(); + + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { totpSecret: "MOCKSECRET" }, + }); + expect(result).toEqual({ + secret: "MOCKSECRET", + uri: "otpauth://mock", + }); + }); + + it("rejects enabling TOTP when no secret was generated", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + totpSecret: null, + totpEnabled: false, + }); + const update = vi.fn(); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + await expect(caller.verifyAndEnableTotp({ token: "123456" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "No TOTP secret generated. Call generateTotpSecret first.", + }); + + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects enabling TOTP when it is already active", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: true, + }); + const update = vi.fn(); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + await expect(caller.verifyAndEnableTotp({ token: "123456" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "TOTP is already enabled.", + }); + + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects invalid TOTP tokens during self-service enablement", async () => { + totpValidateMock.mockReset(); + totpValidateMock.mockReturnValue(null); + + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: false, + }); + const update = vi.fn(); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + await expect(caller.verifyAndEnableTotp({ token: "123456" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Invalid TOTP token.", + }); + + expect(totpValidateMock).toHaveBeenCalledWith({ + token: "123456", + window: 1, + }); + expect(update).not.toHaveBeenCalled(); + }); + + it("enables TOTP for the current user when the token validates", async () => { + totpValidateMock.mockReset(); + totpValidateMock.mockReturnValue(0); + + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + name: "Admin", + email: "admin@example.com", + totpSecret: "MOCKSECRET", + totpEnabled: false, + }); + const update = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.verifyAndEnableTotp({ token: "123456" }); + + expect(result).toEqual({ enabled: true }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { totpEnabled: true }, + }); + }); + + it("verifies login-flow TOTP tokens successfully when enabled", async () => { + totpValidateMock.mockReset(); + totpValidateMock.mockReturnValue(0); + + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + totpSecret: "MOCKSECRET", + totpEnabled: true, + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.verifyTotp({ userId: "user_admin", token: "123456" }); + + expect(result).toEqual({ valid: true }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { id: true, totpSecret: true, totpEnabled: true }, + }); + }); + + it("rejects invalid login-flow TOTP tokens with UNAUTHORIZED", async () => { + totpValidateMock.mockReset(); + totpValidateMock.mockReturnValue(null); + + const findUnique = vi.fn().mockResolvedValue({ + id: "user_admin", + totpSecret: "MOCKSECRET", + totpEnabled: true, + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + await expect(caller.verifyTotp({ userId: "user_admin", token: "123456" })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Invalid TOTP token.", + }); + }); +}); + +describe("user dashboard and favorites", () => { + it("falls back to the normalized default dashboard layout when stored data is invalid", async () => { + const findUnique = vi.fn().mockResolvedValue({ + dashboardLayout: { + widgets: [ + { id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }, + ], + }, + updatedAt: new Date("2026-03-30T18:00:00.000Z"), + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.getDashboardLayout(); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { dashboardLayout: true, updatedAt: true }, + }); + expect(result).toEqual({ + layout: { + version: 2, + gridCols: 12, + widgets: [], + }, + updatedAt: new Date("2026-03-30T18:00:00.000Z"), + }); + }); + + it("persists a valid dashboard layout for the current user", async () => { + const update = vi.fn().mockResolvedValue({ + updatedAt: new Date("2026-03-30T19:00:00.000Z"), + }); + const caller = createAdminCaller({ + user: { + update, + }, + }); + + const layout = { + version: 2, + gridCols: 12, + widgets: [], + }; + + const result = await caller.saveDashboardLayout({ layout }); + + expect(result).toEqual({ + updatedAt: new Date("2026-03-30T19:00:00.000Z"), + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { dashboardLayout: layout }, + select: { updatedAt: true }, + }); + }); + + it("persists dashboard layout by db user id even when the session email is stale", async () => { + const update = vi.fn().mockResolvedValue({ + updatedAt: new Date("2026-03-30T19:30:00.000Z"), + }); + const caller = createCaller({ + session: { + user: { email: "stale@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: { + user: { + update, + }, + } as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); + + await caller.saveDashboardLayout({ + layout: { + version: 2, + gridCols: 12, + widgets: [], + }, + }); + + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { + dashboardLayout: { + version: 2, + gridCols: 12, + widgets: [], + }, + }, + select: { updatedAt: true }, + }); + }); + + it("returns favorite project ids as an empty list when none are stored", async () => { + const findUnique = vi.fn().mockResolvedValue({ + favoriteProjectIds: null, + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.getFavoriteProjectIds(); + + expect(result).toEqual([]); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { favoriteProjectIds: true }, + }); + }); + + it("adds a project to favorites when it is not already present", async () => { + const findUnique = vi.fn().mockResolvedValue({ + favoriteProjectIds: ["project_1"], + }); + const update = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.toggleFavoriteProject({ projectId: "project_2" }); + + expect(result).toEqual({ + favoriteProjectIds: ["project_1", "project_2"], + added: true, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { favoriteProjectIds: ["project_1", "project_2"] }, + }); + }); + + it("removes a project from favorites when it is already present", async () => { + const findUnique = vi.fn().mockResolvedValue({ + favoriteProjectIds: ["project_1", "project_2"], + }); + const update = vi.fn().mockResolvedValue({}); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.toggleFavoriteProject({ projectId: "project_2" }); + + expect(result).toEqual({ + favoriteProjectIds: ["project_1"], + added: false, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { favoriteProjectIds: ["project_1"] }, + }); + }); +}); + +describe("user column preferences and MFA status", () => { + it("returns empty column preferences when nothing is stored", async () => { + const findUnique = vi.fn().mockResolvedValue({ + columnPreferences: null, + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.getColumnPreferences(); + + expect(result).toEqual({}); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { columnPreferences: true }, + }); + }); + + it("merges column preferences without dropping untouched fields", async () => { + const findUnique = vi.fn().mockResolvedValue({ + columnPreferences: { + resources: { + visible: ["name", "role"], + sort: { field: "name", dir: "asc" }, + rowOrder: ["name", "role", "email"], + }, + }, + }); + const update = vi.fn().mockResolvedValue({ id: "user_admin" }); + const caller = createAdminCaller({ + user: { + findUnique, + update, + }, + }); + + const result = await caller.setColumnPreferences({ + view: "resources", + visible: ["name", "email"], + }); + + expect(result).toEqual({ ok: true }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { + columnPreferences: { + resources: { + visible: ["name", "email"], + sort: { field: "name", dir: "asc" }, + rowOrder: ["name", "role", "email"], + }, + }, + }, + }); + }); + + it("reports the current MFA enabled state", async () => { + const findUnique = vi.fn().mockResolvedValue({ + totpEnabled: true, + }); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + const result = await caller.getMfaStatus(); + + expect(result).toEqual({ totpEnabled: true }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + select: { totpEnabled: true }, + }); + }); + + it("returns NOT_FOUND for MFA status when the current user record no longer exists", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ + user: { + findUnique, + }, + }); + + await expect(caller.getMfaStatus()).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "User not found", }); }); }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index ac4073d..3bdb6bd 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -57,7 +57,7 @@ export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ - where: { email: ctx.session.user?.email ?? "" }, + where: { id: ctx.dbUser!.id }, select: { id: true, name: true, @@ -255,24 +255,79 @@ export const userRouter = createTRPCRouter({ ); if (input.resourceId) { - await findUniqueOrThrow( + const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId }, - select: { id: true }, + select: { id: true, userId: true }, }), "Resource", ); - // Unlink any resource previously linked to this user + if (resource.userId && resource.userId !== input.userId) { + throw new TRPCError({ + code: "CONFLICT", + message: "Resource is already linked to another user", + }); + } + + // Unlink any other resource previously linked to this user. await ctx.db.resource.updateMany({ - where: { userId: input.userId }, + where: { + userId: input.userId, + NOT: { id: input.resourceId }, + }, data: { userId: null }, }); - // Link the new resource - await ctx.db.resource.update({ - where: { id: input.resourceId }, + + const linkResult = await ctx.db.resource.updateMany({ + where: { + id: input.resourceId, + OR: [ + { userId: null }, + { userId: input.userId }, + ], + }, data: { userId: input.userId }, }); + + if (linkResult.count !== 1) { + const [userStillExists, resourceStillExists] = await Promise.all([ + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true }, + }), + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { id: true, userId: true }, + }), + ]); + + if (!userStillExists) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + if (!resourceStillExists) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); + } + + if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) { + throw new TRPCError({ + code: "CONFLICT", + message: "Resource is already linked to another user", + }); + } + + throw new TRPCError({ + code: "CONFLICT", + message: "Resource link changed during update. Please retry.", + }); + } } else { // Unlink await ctx.db.resource.updateMany({ @@ -309,7 +364,7 @@ export const userRouter = createTRPCRouter({ getDashboardLayout: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ - where: { email: ctx.session.user?.email ?? "" }, + where: { id: ctx.dbUser!.id }, select: { dashboardLayout: true, updatedAt: true }, }); return { @@ -322,7 +377,7 @@ export const userRouter = createTRPCRouter({ .input(z.object({ layout: dashboardLayoutSchema })) .mutation(async ({ ctx, input }) => { const updated = await ctx.db.user.update({ - where: { email: ctx.session.user?.email ?? "" }, + where: { id: ctx.dbUser!.id }, data: { dashboardLayout: input.layout as unknown as import("@capakraken/db").Prisma.InputJsonValue }, select: { updatedAt: true }, }); @@ -531,10 +586,13 @@ export const userRouter = createTRPCRouter({ verifyAndEnableTotp: protectedProcedure .input(z.object({ token: z.string().length(6) })) .mutation(async ({ ctx, input }) => { - const user = await ctx.db.user.findUniqueOrThrow({ - where: { id: ctx.dbUser!.id }, - select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, - }); + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: ctx.dbUser!.id }, + select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, + }), + "User", + ); if (!user.totpSecret) { throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." }); @@ -612,10 +670,13 @@ export const userRouter = createTRPCRouter({ verifyTotp: publicProcedure .input(z.object({ userId: z.string(), token: z.string().length(6) })) .mutation(async ({ ctx, input }) => { - const user = await ctx.db.user.findUniqueOrThrow({ - where: { id: input.userId }, - select: { id: true, totpSecret: true, totpEnabled: true }, - }); + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: input.userId }, + select: { id: true, totpSecret: true, totpEnabled: true }, + }), + "User", + ); if (!user.totpEnabled || !user.totpSecret) { throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." }); @@ -641,10 +702,13 @@ export const userRouter = createTRPCRouter({ /** Get MFA status for the current user. */ getMfaStatus: protectedProcedure.query(async ({ ctx }) => { - const user = await ctx.db.user.findUniqueOrThrow({ - where: { id: ctx.dbUser!.id }, - select: { totpEnabled: true }, - }); + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { id: ctx.dbUser!.id }, + select: { totpEnabled: true }, + }), + "User", + ); return { totpEnabled: user.totpEnabled }; }), });