fix(api): harden user self-service and resource linking
This commit is contained in:
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+71
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+147
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+116
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
|
||||||
|
import type { ToolContext } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
|
export function createToolContext(
|
||||||
|
db: Record<string, unknown>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+99
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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;
|
||||||
@@ -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<typeof import("@capakraken/application")>();
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
|
||||||
|
import type { ToolContext } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
|
export function createToolContext(
|
||||||
|
db: Record<string, unknown>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -85,6 +85,82 @@ describe("user router authorization", () => {
|
|||||||
expect(update).not.toHaveBeenCalled();
|
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 () => {
|
it("forbids regular users from listing assignable users", async () => {
|
||||||
const findMany = vi.fn();
|
const findMany = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(createContext({
|
||||||
@@ -140,6 +216,49 @@ describe("user router authorization", () => {
|
|||||||
expect(findUnique).not.toHaveBeenCalled();
|
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 () => {
|
it("forbids non-admin users from disabling TOTP for other users", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(createContext({
|
||||||
@@ -186,15 +305,39 @@ describe("user router authorization", () => {
|
|||||||
expect(update).not.toHaveBeenCalled();
|
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 () => {
|
it("keeps TOTP verification public for the login flow", async () => {
|
||||||
const findUniqueOrThrow = vi.fn().mockResolvedValue({
|
const findUnique = vi.fn().mockResolvedValue({
|
||||||
id: "user_1",
|
id: "user_1",
|
||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
});
|
});
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(createContext({
|
||||||
user: {
|
user: {
|
||||||
findUniqueOrThrow,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
}, { session: false }));
|
||||||
|
|
||||||
@@ -203,7 +346,7 @@ describe("user router authorization", () => {
|
|||||||
message: "TOTP is not enabled for this user.",
|
message: "TOTP is not enabled for this user.",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findUniqueOrThrow).toHaveBeenCalledWith({
|
expect(findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "user_1" },
|
where: { id: "user_1" },
|
||||||
select: { id: true, totpSecret: true, totpEnabled: true },
|
select: { id: true, totpSecret: true, totpEnabled: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { describe, expect, it, vi } from "vitest";
|
||||||
import { userRouter } from "../router/user.js";
|
import { userRouter } from "../router/user.js";
|
||||||
import { createCallerFactory } from "../trpc.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);
|
const createCaller = createCallerFactory(userRouter);
|
||||||
|
|
||||||
function createAdminCaller(db: Record<string, unknown>) {
|
function createAdminCaller(db: Record<string, unknown>) {
|
||||||
@@ -59,7 +102,6 @@ describe("user.linkResource", () => {
|
|||||||
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
||||||
const resourceFindUnique = vi.fn().mockResolvedValue(null);
|
const resourceFindUnique = vi.fn().mockResolvedValue(null);
|
||||||
const updateMany = vi.fn();
|
const updateMany = vi.fn();
|
||||||
const update = vi.fn();
|
|
||||||
const caller = createAdminCaller({
|
const caller = createAdminCaller({
|
||||||
user: {
|
user: {
|
||||||
findUnique: userFindUnique,
|
findUnique: userFindUnique,
|
||||||
@@ -67,7 +109,6 @@ describe("user.linkResource", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: resourceFindUnique,
|
findUnique: resourceFindUnique,
|
||||||
updateMany,
|
updateMany,
|
||||||
update,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,17 +122,42 @@ describe("user.linkResource", () => {
|
|||||||
|
|
||||||
expect(resourceFindUnique).toHaveBeenCalledWith({
|
expect(resourceFindUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "missing_resource" },
|
where: { id: "missing_resource" },
|
||||||
select: { id: true },
|
select: { id: true, userId: true },
|
||||||
});
|
});
|
||||||
expect(updateMany).not.toHaveBeenCalled();
|
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 userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
||||||
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1" });
|
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: "user_2" });
|
||||||
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
|
const updateMany = vi.fn();
|
||||||
const update = vi.fn().mockResolvedValue({ id: "resource_1", userId: "user_1" });
|
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({
|
const caller = createAdminCaller({
|
||||||
user: {
|
user: {
|
||||||
findUnique: userFindUnique,
|
findUnique: userFindUnique,
|
||||||
@@ -99,7 +165,6 @@ describe("user.linkResource", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: resourceFindUnique,
|
findUnique: resourceFindUnique,
|
||||||
updateMany,
|
updateMany,
|
||||||
update,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,13 +174,850 @@ describe("user.linkResource", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
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({
|
expect(updateMany).toHaveBeenCalledWith({
|
||||||
where: { userId: "user_1" },
|
where: { userId: "user_1" },
|
||||||
data: { userId: null },
|
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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
me: protectedProcedure.query(async ({ ctx }) => {
|
me: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await findUniqueOrThrow(
|
const user = await findUniqueOrThrow(
|
||||||
ctx.db.user.findUnique({
|
ctx.db.user.findUnique({
|
||||||
where: { email: ctx.session.user?.email ?? "" },
|
where: { id: ctx.dbUser!.id },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -255,24 +255,79 @@ export const userRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (input.resourceId) {
|
if (input.resourceId) {
|
||||||
await findUniqueOrThrow(
|
const resource = await findUniqueOrThrow(
|
||||||
ctx.db.resource.findUnique({
|
ctx.db.resource.findUnique({
|
||||||
where: { id: input.resourceId },
|
where: { id: input.resourceId },
|
||||||
select: { id: true },
|
select: { id: true, userId: true },
|
||||||
}),
|
}),
|
||||||
"Resource",
|
"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({
|
await ctx.db.resource.updateMany({
|
||||||
where: { userId: input.userId },
|
where: {
|
||||||
|
userId: input.userId,
|
||||||
|
NOT: { id: input.resourceId },
|
||||||
|
},
|
||||||
data: { userId: null },
|
data: { userId: null },
|
||||||
});
|
});
|
||||||
// Link the new resource
|
|
||||||
await ctx.db.resource.update({
|
const linkResult = await ctx.db.resource.updateMany({
|
||||||
where: { id: input.resourceId },
|
where: {
|
||||||
|
id: input.resourceId,
|
||||||
|
OR: [
|
||||||
|
{ userId: null },
|
||||||
|
{ userId: input.userId },
|
||||||
|
],
|
||||||
|
},
|
||||||
data: { 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 {
|
} else {
|
||||||
// Unlink
|
// Unlink
|
||||||
await ctx.db.resource.updateMany({
|
await ctx.db.resource.updateMany({
|
||||||
@@ -309,7 +364,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
getDashboardLayout: protectedProcedure.query(async ({ ctx }) => {
|
getDashboardLayout: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.user.findUnique({
|
const user = await ctx.db.user.findUnique({
|
||||||
where: { email: ctx.session.user?.email ?? "" },
|
where: { id: ctx.dbUser!.id },
|
||||||
select: { dashboardLayout: true, updatedAt: true },
|
select: { dashboardLayout: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -322,7 +377,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
.input(z.object({ layout: dashboardLayoutSchema }))
|
.input(z.object({ layout: dashboardLayoutSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const updated = await ctx.db.user.update({
|
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 },
|
data: { dashboardLayout: input.layout as unknown as import("@capakraken/db").Prisma.InputJsonValue },
|
||||||
select: { updatedAt: true },
|
select: { updatedAt: true },
|
||||||
});
|
});
|
||||||
@@ -531,10 +586,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
verifyAndEnableTotp: protectedProcedure
|
verifyAndEnableTotp: protectedProcedure
|
||||||
.input(z.object({ token: z.string().length(6) }))
|
.input(z.object({ token: z.string().length(6) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = await ctx.db.user.findUniqueOrThrow({
|
const user = await findUniqueOrThrow(
|
||||||
where: { id: ctx.dbUser!.id },
|
ctx.db.user.findUnique({
|
||||||
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
|
where: { id: ctx.dbUser!.id },
|
||||||
});
|
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
|
||||||
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
if (!user.totpSecret) {
|
if (!user.totpSecret) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
|
||||||
@@ -612,10 +670,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
verifyTotp: publicProcedure
|
verifyTotp: publicProcedure
|
||||||
.input(z.object({ userId: z.string(), token: z.string().length(6) }))
|
.input(z.object({ userId: z.string(), token: z.string().length(6) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = await ctx.db.user.findUniqueOrThrow({
|
const user = await findUniqueOrThrow(
|
||||||
where: { id: input.userId },
|
ctx.db.user.findUnique({
|
||||||
select: { id: true, totpSecret: true, totpEnabled: true },
|
where: { id: input.userId },
|
||||||
});
|
select: { id: true, totpSecret: true, totpEnabled: true },
|
||||||
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
|
|
||||||
if (!user.totpEnabled || !user.totpSecret) {
|
if (!user.totpEnabled || !user.totpSecret) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." });
|
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. */
|
/** Get MFA status for the current user. */
|
||||||
getMfaStatus: protectedProcedure.query(async ({ ctx }) => {
|
getMfaStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.user.findUniqueOrThrow({
|
const user = await findUniqueOrThrow(
|
||||||
where: { id: ctx.dbUser!.id },
|
ctx.db.user.findUnique({
|
||||||
select: { totpEnabled: true },
|
where: { id: ctx.dbUser!.id },
|
||||||
});
|
select: { totpEnabled: true },
|
||||||
|
}),
|
||||||
|
"User",
|
||||||
|
);
|
||||||
return { totpEnabled: user.totpEnabled };
|
return { totpEnabled: user.totpEnabled };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user