fix(tests): align 20 drifted tests with current source behavior
Tests fell behind source changes: lastTotpAt replay-attack prevention, activeSession invalidation on password reset, select clauses in permission updates, UNAUTHORIZED (anti-enumeration) for disabled TOTP, and password minimum raised from 8 to 12 characters. Also fix root eslint.config.mjs to ignore packages/ (linted via turbo) and add --no-warn-ignored to lint-staged to suppress warnings for ignored files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
"*.{ts,tsx}": ["eslint --fix --no-warn-ignored", "prettier --write"],
|
||||||
"*.{json,md}": ["prettier --write"]
|
"*.{json,md}": ["prettier --write"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default [
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"apps/**",
|
"apps/**",
|
||||||
|
"packages/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
".claude/**",
|
".claude/**",
|
||||||
"backups/**",
|
"backups/**",
|
||||||
|
|||||||
@@ -31,13 +31,15 @@ describe("assistant user admin password and role errors", () => {
|
|||||||
|
|
||||||
const result = await executeTool(
|
const result = await executeTool(
|
||||||
"set_user_password",
|
"set_user_password",
|
||||||
JSON.stringify({ userId: "user_missing", password: "secret123" }),
|
JSON.stringify({ userId: "user_missing", password: "SecurePass123!" }),
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
error: "User not found with the given criteria.",
|
error: "User not found with the given criteria.",
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a stable error when resetting a password that is too short", async () => {
|
it("returns a stable error when resetting a password that is too short", async () => {
|
||||||
@@ -56,9 +58,11 @@ describe("assistant user admin password and role errors", () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
error: "Password must be at least 8 characters.",
|
expect.objectContaining({
|
||||||
}));
|
error: "Password must be at least 12 characters.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,8 +82,10 @@ describe("assistant user admin password and role errors", () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
error: "User not found with the given criteria.",
|
error: "User not found with the given criteria.",
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ describe("assistant user admin tools user create errors", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
email: "peter.parker@example.com",
|
email: "peter.parker@example.com",
|
||||||
name: "Peter Parker",
|
name: "Peter Parker",
|
||||||
password: "secret123",
|
password: "SecurePass123!",
|
||||||
}),
|
}),
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
error: "User with this email already exists.",
|
error: "User with this email already exists.",
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a stable error when creating a user without a name", async () => {
|
it("returns a stable error when creating a user without a name", async () => {
|
||||||
@@ -63,14 +65,16 @@ describe("assistant user admin tools user create errors", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
email: "miles.morales@example.com",
|
email: "miles.morales@example.com",
|
||||||
name: "",
|
name: "",
|
||||||
password: "secret123",
|
password: "SecurePass123!",
|
||||||
}),
|
}),
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
error: "Name is required.",
|
error: "Name is required.",
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,9 +98,11 @@ describe("assistant user admin tools user create errors", () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
error: "Password must be at least 8 characters.",
|
expect.objectContaining({
|
||||||
}));
|
error: "Password must be at least 12 characters.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,11 +66,18 @@ describe("assistant user self-service MFA tools - enable flow", () => {
|
|||||||
|
|
||||||
expect(db.user.findUnique).toHaveBeenCalledWith({
|
expect(db.user.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "user_1" },
|
where: { id: "user_1" },
|
||||||
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
totpSecret: true,
|
||||||
|
totpEnabled: true,
|
||||||
|
lastTotpAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(db.user.update).toHaveBeenCalledWith({
|
expect(db.user.update).toHaveBeenCalledWith({
|
||||||
where: { id: "user_1" },
|
where: { id: "user_1" },
|
||||||
data: { totpEnabled: true },
|
data: { totpEnabled: true, lastTotpAt: expect.any(Date) },
|
||||||
});
|
});
|
||||||
expect(db.auditLog.create).toHaveBeenCalledWith({
|
expect(db.auditLog.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
|
|||||||
@@ -20,16 +20,21 @@ vi.mock("@node-rs/argon2", () => ({ hash: vi.fn().mockResolvedValue("$argon2id$n
|
|||||||
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
const PAST = new Date(Date.now() - 1000);
|
const PAST = new Date(Date.now() - 1000);
|
||||||
|
|
||||||
function makeDb(overrides: {
|
function makeDb(
|
||||||
|
overrides: {
|
||||||
user?: Partial<Record<string, unknown>>;
|
user?: Partial<Record<string, unknown>>;
|
||||||
resetToken?: Partial<Record<string, unknown>>;
|
resetToken?: Partial<Record<string, unknown>>;
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: vi.fn().mockResolvedValue({ id: "user_1" }),
|
||||||
...overrides.user,
|
...overrides.user,
|
||||||
},
|
},
|
||||||
|
activeSession: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
passwordResetToken: {
|
passwordResetToken: {
|
||||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
create: vi.fn().mockResolvedValue({}),
|
create: vi.fn().mockResolvedValue({}),
|
||||||
@@ -68,7 +73,10 @@ describe("auth.requestPasswordReset", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(sendEmail).toHaveBeenCalledWith(
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ to: "user@example.com", subject: expect.stringContaining("reset") }),
|
expect.objectContaining({
|
||||||
|
to: "user@example.com",
|
||||||
|
subject: expect.stringContaining("reset"),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +100,8 @@ describe("auth.requestPasswordReset", () => {
|
|||||||
await authRouter.createCaller(ctx).requestPasswordReset({ email: "user@example.com" });
|
await authRouter.createCaller(ctx).requestPasswordReset({ email: "user@example.com" });
|
||||||
|
|
||||||
// deleteMany called before create
|
// deleteMany called before create
|
||||||
const deleteManyOrder = vi.mocked(db.passwordResetToken.deleteMany).mock.invocationCallOrder[0]!;
|
const deleteManyOrder = vi.mocked(db.passwordResetToken.deleteMany).mock
|
||||||
|
.invocationCallOrder[0]!;
|
||||||
const createOrder = vi.mocked(db.passwordResetToken.create).mock.invocationCallOrder[0]!;
|
const createOrder = vi.mocked(db.passwordResetToken.create).mock.invocationCallOrder[0]!;
|
||||||
expect(deleteManyOrder).toBeLessThan(createOrder);
|
expect(deleteManyOrder).toBeLessThan(createOrder);
|
||||||
});
|
});
|
||||||
@@ -115,13 +124,14 @@ describe("auth.resetPassword", () => {
|
|||||||
|
|
||||||
const result = await authRouter.createCaller(ctx).resetPassword({
|
const result = await authRouter.createCaller(ctx).resetPassword({
|
||||||
token: "valid-token",
|
token: "valid-token",
|
||||||
password: "NewPassword1!",
|
password: "NewPassword12!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
expect(db.user.update).toHaveBeenCalledWith({
|
expect(db.user.update).toHaveBeenCalledWith({
|
||||||
where: { email: "user@example.com" },
|
where: { email: "user@example.com" },
|
||||||
data: { passwordHash: "$argon2id$newhash" },
|
data: { passwordHash: "$argon2id$newhash" },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
expect(db.passwordResetToken.update).toHaveBeenCalledWith({
|
expect(db.passwordResetToken.update).toHaveBeenCalledWith({
|
||||||
where: { token: "valid-token" },
|
where: { token: "valid-token" },
|
||||||
@@ -134,12 +144,12 @@ describe("auth.resetPassword", () => {
|
|||||||
const ctx = makeCtx(db);
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1!" }),
|
authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1234!" }),
|
||||||
).rejects.toThrow(TRPCError);
|
).rejects.toThrow(TRPCError);
|
||||||
|
|
||||||
const err = await authRouter
|
const err = await authRouter
|
||||||
.createCaller(ctx)
|
.createCaller(ctx)
|
||||||
.resetPassword({ token: "bad-token", password: "Password1!" })
|
.resetPassword({ token: "bad-token", password: "Password1234!" })
|
||||||
.catch((e: TRPCError) => e);
|
.catch((e: TRPCError) => e);
|
||||||
expect((err as TRPCError).code).toBe("NOT_FOUND");
|
expect((err as TRPCError).code).toBe("NOT_FOUND");
|
||||||
});
|
});
|
||||||
@@ -156,7 +166,7 @@ describe("auth.resetPassword", () => {
|
|||||||
|
|
||||||
const err = await authRouter
|
const err = await authRouter
|
||||||
.createCaller(ctx)
|
.createCaller(ctx)
|
||||||
.resetPassword({ token: "used-token", password: "Password1!" })
|
.resetPassword({ token: "used-token", password: "Password1234!" })
|
||||||
.catch((e: TRPCError) => e);
|
.catch((e: TRPCError) => e);
|
||||||
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
||||||
expect((err as TRPCError).message).toMatch(/already been used/);
|
expect((err as TRPCError).message).toMatch(/already been used/);
|
||||||
@@ -174,7 +184,7 @@ describe("auth.resetPassword", () => {
|
|||||||
|
|
||||||
const err = await authRouter
|
const err = await authRouter
|
||||||
.createCaller(ctx)
|
.createCaller(ctx)
|
||||||
.resetPassword({ token: "expired-token", password: "Password1!" })
|
.resetPassword({ token: "expired-token", password: "Password1234!" })
|
||||||
.catch((e: TRPCError) => e);
|
.catch((e: TRPCError) => e);
|
||||||
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
||||||
expect((err as TRPCError).message).toMatch(/expired/);
|
expect((err as TRPCError).message).toMatch(/expired/);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ function makeCtx(userRow: Record<string, unknown> | null = null) {
|
|||||||
findUnique: vi.fn().mockResolvedValue(userRow),
|
findUnique: vi.fn().mockResolvedValue(userRow),
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
|
activeSession: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
} as never,
|
} as never,
|
||||||
dbUser: { id: "admin_1" },
|
dbUser: { id: "admin_1" },
|
||||||
};
|
};
|
||||||
@@ -38,13 +41,15 @@ const EXISTING_USER = { id: "user_1", name: "Alice", email: "alice@example.com"
|
|||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("setUserPassword — happy path", () => {
|
describe("setUserPassword — happy path", () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); });
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("hashes the new password and updates the DB", async () => {
|
it("hashes the new password and updates the DB", async () => {
|
||||||
const ctx = makeCtx(EXISTING_USER);
|
const ctx = makeCtx(EXISTING_USER);
|
||||||
const result = await setUserPassword(ctx, { userId: "user_1", password: "NewPass123!" });
|
const result = await setUserPassword(ctx, { userId: "user_1", password: "NewPassword123!" });
|
||||||
|
|
||||||
expect(hashMock).toHaveBeenCalledWith("NewPass123!");
|
expect(hashMock).toHaveBeenCalledWith("NewPassword123!");
|
||||||
expect(ctx.db.user.update).toHaveBeenCalledWith(
|
expect(ctx.db.user.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { id: "user_1" },
|
where: { id: "user_1" },
|
||||||
@@ -57,7 +62,7 @@ describe("setUserPassword — happy path", () => {
|
|||||||
it("creates an audit entry with summary 'Password reset by admin'", async () => {
|
it("creates an audit entry with summary 'Password reset by admin'", async () => {
|
||||||
const { createAuditEntry } = await import("../lib/audit.js");
|
const { createAuditEntry } = await import("../lib/audit.js");
|
||||||
const ctx = makeCtx(EXISTING_USER);
|
const ctx = makeCtx(EXISTING_USER);
|
||||||
await setUserPassword(ctx, { userId: "user_1", password: "NewPass123!" });
|
await setUserPassword(ctx, { userId: "user_1", password: "NewPassword123!" });
|
||||||
|
|
||||||
// createAuditEntry is called fire-and-forget (void), so we give microtasks a tick
|
// createAuditEntry is called fire-and-forget (void), so we give microtasks a tick
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -68,23 +73,28 @@ describe("setUserPassword — happy path", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("setUserPassword — not found", () => {
|
describe("setUserPassword — not found", () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); });
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when the user does not exist", async () => {
|
it("throws when the user does not exist", async () => {
|
||||||
const ctx = makeCtx(null); // findUnique returns null
|
const ctx = makeCtx(null); // findUnique returns null
|
||||||
await expect(
|
await expect(
|
||||||
setUserPassword(ctx, { userId: "ghost", password: "NewPass123!" }),
|
setUserPassword(ctx, { userId: "ghost", password: "NewPassword123!" }),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SetUserPasswordInputSchema — validation", () => {
|
describe("SetUserPasswordInputSchema — validation", () => {
|
||||||
it("accepts a valid input", () => {
|
it("accepts a valid input", () => {
|
||||||
const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "Valid123!" });
|
const result = SetUserPasswordInputSchema.safeParse({
|
||||||
|
userId: "u1",
|
||||||
|
password: "ValidPass123!",
|
||||||
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a password shorter than 8 characters", () => {
|
it("rejects a password shorter than 12 characters", () => {
|
||||||
const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "short" });
|
const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "short" });
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ function createContext(
|
|||||||
describe("user router authorization", () => {
|
describe("user router authorization", () => {
|
||||||
it("requires authentication for self-service profile lookups", async () => {
|
it("requires authentication for self-service profile lookups", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.me()).rejects.toMatchObject({
|
await expect(caller.me()).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
@@ -52,11 +57,16 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
it("requires authentication for dashboard layout reads", async () => {
|
it("requires authentication for dashboard layout reads", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getDashboardLayout()).rejects.toMatchObject({
|
await expect(caller.getDashboardLayout()).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
@@ -69,12 +79,17 @@ describe("user router authorization", () => {
|
|||||||
it("requires authentication for favorite project toggles", async () => {
|
it("requires authentication for favorite project toggles", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.toggleFavoriteProject({ projectId: "project_1" })).rejects.toMatchObject({
|
await expect(caller.toggleFavoriteProject({ projectId: "project_1" })).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
@@ -87,15 +102,22 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
it("requires authentication for dashboard layout saves", async () => {
|
it("requires authentication for dashboard layout saves", async () => {
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.saveDashboardLayout({
|
await expect(
|
||||||
|
caller.saveDashboardLayout({
|
||||||
layout: { version: 2, gridCols: 12, widgets: [] },
|
layout: { version: 2, gridCols: 12, widgets: [] },
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Authentication required",
|
message: "Authentication required",
|
||||||
});
|
});
|
||||||
@@ -105,11 +127,16 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
it("requires authentication for favorite project reads", async () => {
|
it("requires authentication for favorite project reads", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getFavoriteProjectIds()).rejects.toMatchObject({
|
await expect(caller.getFavoriteProjectIds()).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
@@ -122,21 +149,28 @@ describe("user router authorization", () => {
|
|||||||
it("requires authentication for column preference reads and writes", async () => {
|
it("requires authentication for column preference reads and writes", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getColumnPreferences()).rejects.toMatchObject({
|
await expect(caller.getColumnPreferences()).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Authentication required",
|
message: "Authentication required",
|
||||||
});
|
});
|
||||||
await expect(caller.setColumnPreferences({
|
await expect(
|
||||||
|
caller.setColumnPreferences({
|
||||||
view: "resources",
|
view: "resources",
|
||||||
visible: ["name"],
|
visible: ["name"],
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Authentication required",
|
message: "Authentication required",
|
||||||
});
|
});
|
||||||
@@ -147,11 +181,16 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
it("requires authentication for MFA status lookups", async () => {
|
it("requires authentication for MFA status lookups", async () => {
|
||||||
const findUniqueOrThrow = vi.fn();
|
const findUniqueOrThrow = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUniqueOrThrow,
|
findUniqueOrThrow,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getMfaStatus()).rejects.toMatchObject({
|
await expect(caller.getMfaStatus()).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
@@ -163,11 +202,13 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
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({
|
||||||
user: {
|
user: {
|
||||||
findMany,
|
findMany,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.listAssignable()).rejects.toMatchObject({
|
await expect(caller.listAssignable()).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -178,14 +219,19 @@ describe("user router authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows managers to list assignable users", async () => {
|
it("allows managers to list assignable users", async () => {
|
||||||
const findMany = vi.fn().mockResolvedValue([
|
const findMany = vi
|
||||||
{ id: "user_1", name: "Alice", email: "alice@example.com" },
|
.fn()
|
||||||
]);
|
.mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findMany,
|
findMany,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await caller.listAssignable();
|
const result = await caller.listAssignable();
|
||||||
|
|
||||||
@@ -202,11 +248,16 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
it("forbids non-admin users from reading effective permissions", async () => {
|
it("forbids non-admin users from reading effective permissions", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.getEffectivePermissions({ userId: "user_2" })).rejects.toMatchObject({
|
await expect(caller.getEffectivePermissions({ userId: "user_2" })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -219,19 +270,26 @@ describe("user router authorization", () => {
|
|||||||
it("forbids non-admin users from setting explicit permission overrides", async () => {
|
it("forbids non-admin users from setting explicit permission overrides", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.setPermissions({
|
await expect(
|
||||||
|
caller.setPermissions({
|
||||||
userId: "user_2",
|
userId: "user_2",
|
||||||
overrides: {
|
overrides: {
|
||||||
granted: ["manageProjects"],
|
granted: ["manageProjects"],
|
||||||
},
|
},
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Admin role required",
|
message: "Admin role required",
|
||||||
});
|
});
|
||||||
@@ -243,12 +301,17 @@ describe("user router authorization", () => {
|
|||||||
it("forbids non-admin users from resetting permission overrides", async () => {
|
it("forbids non-admin users from resetting permission overrides", async () => {
|
||||||
const findUnique = vi.fn();
|
const findUnique = vi.fn();
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.resetPermissions({ userId: "user_2" })).rejects.toMatchObject({
|
await expect(caller.resetPermissions({ userId: "user_2" })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -261,11 +324,16 @@ describe("user router authorization", () => {
|
|||||||
|
|
||||||
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(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.disableTotp({ userId: "user_2" })).rejects.toMatchObject({
|
await expect(caller.disableTotp({ userId: "user_2" })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -280,7 +348,9 @@ describe("user router authorization", () => {
|
|||||||
const resourceFindUnique = vi.fn();
|
const resourceFindUnique = vi.fn();
|
||||||
const updateMany = vi.fn();
|
const updateMany = vi.fn();
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique: userFindUnique,
|
findUnique: userFindUnique,
|
||||||
},
|
},
|
||||||
@@ -289,12 +359,17 @@ describe("user router authorization", () => {
|
|||||||
updateMany,
|
updateMany,
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.linkResource({
|
await expect(
|
||||||
|
caller.linkResource({
|
||||||
userId: "user_2",
|
userId: "user_2",
|
||||||
resourceId: "resource_1",
|
resourceId: "resource_1",
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Admin role required",
|
message: "Admin role required",
|
||||||
});
|
});
|
||||||
@@ -309,7 +384,9 @@ describe("user router authorization", () => {
|
|||||||
const userFindMany = vi.fn();
|
const userFindMany = vi.fn();
|
||||||
const resourceFindFirst = vi.fn();
|
const resourceFindFirst = vi.fn();
|
||||||
const resourceUpdate = vi.fn();
|
const resourceUpdate = vi.fn();
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findMany: userFindMany,
|
findMany: userFindMany,
|
||||||
},
|
},
|
||||||
@@ -317,7 +394,10 @@ describe("user router authorization", () => {
|
|||||||
findFirst: resourceFindFirst,
|
findFirst: resourceFindFirst,
|
||||||
update: resourceUpdate,
|
update: resourceUpdate,
|
||||||
},
|
},
|
||||||
}, { role: SystemRole.MANAGER }));
|
},
|
||||||
|
{ role: SystemRole.MANAGER },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.autoLinkAllByEmail()).rejects.toMatchObject({
|
await expect(caller.autoLinkAllByEmail()).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
@@ -334,21 +414,27 @@ describe("user router authorization", () => {
|
|||||||
id: "user_1",
|
id: "user_1",
|
||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
|
lastTotpAt: null,
|
||||||
});
|
});
|
||||||
const caller = createCaller(createContext({
|
const caller = createCaller(
|
||||||
|
createContext(
|
||||||
|
{
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
},
|
},
|
||||||
}, { session: false }));
|
},
|
||||||
|
{ session: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(caller.verifyTotp({ userId: "user_1", token: "123456" })).rejects.toMatchObject({
|
await expect(caller.verifyTotp({ userId: "user_1", token: "123456" })).rejects.toMatchObject({
|
||||||
code: "BAD_REQUEST",
|
code: "UNAUTHORIZED",
|
||||||
message: "TOTP is not enabled for this user.",
|
message: "Invalid TOTP token.",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findUnique).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, lastTotpAt: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,10 +81,12 @@ describe("user.linkResource", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(caller.linkResource({
|
await expect(
|
||||||
|
caller.linkResource({
|
||||||
userId: "missing_user",
|
userId: "missing_user",
|
||||||
resourceId: "resource_1",
|
resourceId: "resource_1",
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "User not found",
|
message: "User not found",
|
||||||
});
|
});
|
||||||
@@ -112,10 +114,12 @@ describe("user.linkResource", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(caller.linkResource({
|
await expect(
|
||||||
|
caller.linkResource({
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
resourceId: "missing_resource",
|
resourceId: "missing_resource",
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Resource not found",
|
message: "Resource not found",
|
||||||
});
|
});
|
||||||
@@ -141,10 +145,12 @@ describe("user.linkResource", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(caller.linkResource({
|
await expect(
|
||||||
|
caller.linkResource({
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
resourceId: "resource_1",
|
resourceId: "resource_1",
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: "Resource is already linked to another user",
|
message: "Resource is already linked to another user",
|
||||||
});
|
});
|
||||||
@@ -155,7 +161,8 @@ describe("user.linkResource", () => {
|
|||||||
it("unlinks existing assignments before linking the requested resource", async () => {
|
it("unlinks existing assignments before linking the requested resource", 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", userId: null });
|
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
||||||
const updateMany = vi.fn()
|
const updateMany = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({ count: 1 })
|
.mockResolvedValueOnce({ count: 1 })
|
||||||
.mockResolvedValueOnce({ count: 1 });
|
.mockResolvedValueOnce({ count: 1 });
|
||||||
const caller = createAdminCaller({
|
const caller = createAdminCaller({
|
||||||
@@ -224,7 +231,8 @@ describe("user.linkResource", () => {
|
|||||||
it("returns CONFLICT when the resource link changes between validation and update", async () => {
|
it("returns CONFLICT when the resource link changes between validation and update", 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", userId: null });
|
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
||||||
const updateMany = vi.fn()
|
const updateMany = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({ count: 0 })
|
.mockResolvedValueOnce({ count: 0 })
|
||||||
.mockResolvedValueOnce({ count: 0 });
|
.mockResolvedValueOnce({ count: 0 });
|
||||||
const caller = createAdminCaller({
|
const caller = createAdminCaller({
|
||||||
@@ -237,10 +245,12 @@ describe("user.linkResource", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(caller.linkResource({
|
await expect(
|
||||||
|
caller.linkResource({
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
resourceId: "resource_1",
|
resourceId: "resource_1",
|
||||||
})).rejects.toMatchObject({
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: "Resource link changed during update. Please retry.",
|
message: "Resource link changed during update. Please retry.",
|
||||||
});
|
});
|
||||||
@@ -249,7 +259,9 @@ describe("user.linkResource", () => {
|
|||||||
|
|
||||||
describe("user admin account management", () => {
|
describe("user admin account management", () => {
|
||||||
it("counts users active within the last five minutes", async () => {
|
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 nowSpy = vi
|
||||||
|
.spyOn(Date, "now")
|
||||||
|
.mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
||||||
const count = vi.fn().mockResolvedValue(4);
|
const count = vi.fn().mockResolvedValue(4);
|
||||||
const caller = createAdminCaller({
|
const caller = createAdminCaller({
|
||||||
user: {
|
user: {
|
||||||
@@ -279,24 +291,29 @@ describe("user admin account management", () => {
|
|||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
});
|
});
|
||||||
const update = vi.fn().mockResolvedValue({});
|
const update = vi.fn().mockResolvedValue({});
|
||||||
|
const deleteMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||||
const caller = createAdminCaller({
|
const caller = createAdminCaller({
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
update,
|
update,
|
||||||
},
|
},
|
||||||
|
activeSession: {
|
||||||
|
deleteMany,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await caller.setPassword({
|
const result = await caller.setPassword({
|
||||||
userId: "user_2",
|
userId: "user_2",
|
||||||
password: "secret123",
|
password: "SecurePass123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
expect(argon2HashMock).toHaveBeenCalledWith("secret123");
|
expect(argon2HashMock).toHaveBeenCalledWith("SecurePass123!");
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
where: { id: "user_2" },
|
where: { id: "user_2" },
|
||||||
data: { passwordHash: "hashed-secret" },
|
data: { passwordHash: "hashed-secret" },
|
||||||
});
|
});
|
||||||
|
expect(deleteMany).toHaveBeenCalledWith({ where: { userId: "user_2" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the selected user's display name", async () => {
|
it("updates the selected user's display name", async () => {
|
||||||
@@ -436,6 +453,7 @@ describe("user permission overrides", () => {
|
|||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
where: { id: "user_2" },
|
where: { id: "user_2" },
|
||||||
data: { permissionOverrides: overrides },
|
data: { permissionOverrides: overrides },
|
||||||
|
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,6 +490,7 @@ describe("user permission overrides", () => {
|
|||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
where: { id: "user_2" },
|
where: { id: "user_2" },
|
||||||
data: { permissionOverrides: Prisma.DbNull },
|
data: { permissionOverrides: Prisma.DbNull },
|
||||||
|
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -709,7 +728,7 @@ describe("user profile and TOTP self-service", () => {
|
|||||||
expect(result).toEqual({ enabled: true });
|
expect(result).toEqual({ enabled: true });
|
||||||
expect(update).toHaveBeenCalledWith({
|
expect(update).toHaveBeenCalledWith({
|
||||||
where: { id: "user_admin" },
|
where: { id: "user_admin" },
|
||||||
data: { totpEnabled: true },
|
data: { totpEnabled: true, lastTotpAt: expect.any(Date) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -721,10 +740,13 @@ describe("user profile and TOTP self-service", () => {
|
|||||||
id: "user_admin",
|
id: "user_admin",
|
||||||
totpSecret: "MOCKSECRET",
|
totpSecret: "MOCKSECRET",
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
|
lastTotpAt: null,
|
||||||
});
|
});
|
||||||
|
const update = vi.fn().mockResolvedValue({});
|
||||||
const caller = createAdminCaller({
|
const caller = createAdminCaller({
|
||||||
user: {
|
user: {
|
||||||
findUnique,
|
findUnique,
|
||||||
|
update,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -733,7 +755,11 @@ describe("user profile and TOTP self-service", () => {
|
|||||||
expect(result).toEqual({ valid: true });
|
expect(result).toEqual({ valid: true });
|
||||||
expect(findUnique).toHaveBeenCalledWith({
|
expect(findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "user_admin" },
|
where: { id: "user_admin" },
|
||||||
select: { id: true, totpSecret: true, totpEnabled: true },
|
select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
|
||||||
|
});
|
||||||
|
expect(update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "user_admin" },
|
||||||
|
data: { lastTotpAt: expect.any(Date) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -752,7 +778,9 @@ describe("user profile and TOTP self-service", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(caller.verifyTotp({ userId: "user_admin", token: "123456" })).rejects.toMatchObject({
|
await expect(
|
||||||
|
caller.verifyTotp({ userId: "user_admin", token: "123456" }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid TOTP token.",
|
message: "Invalid TOTP token.",
|
||||||
});
|
});
|
||||||
@@ -763,9 +791,7 @@ describe("user dashboard and favorites", () => {
|
|||||||
it("returns null layout when stored data has no valid widget types (bug #27 guard)", async () => {
|
it("returns null layout when stored data has no valid widget types (bug #27 guard)", async () => {
|
||||||
const findUnique = vi.fn().mockResolvedValue({
|
const findUnique = vi.fn().mockResolvedValue({
|
||||||
dashboardLayout: {
|
dashboardLayout: {
|
||||||
widgets: [
|
widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }],
|
||||||
{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,23 +21,33 @@ const totpValidateMock = vi.hoisted(() => vi.fn<() => number | null>());
|
|||||||
vi.mock("otpauth", () => {
|
vi.mock("otpauth", () => {
|
||||||
class Secret {
|
class Secret {
|
||||||
base32: string;
|
base32: string;
|
||||||
constructor() { this.base32 = "TESTBASE32SECRET"; }
|
constructor() {
|
||||||
static fromBase32(v: string) { return v; }
|
this.base32 = "TESTBASE32SECRET";
|
||||||
|
}
|
||||||
|
static fromBase32(v: string) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
class TOTP {
|
class TOTP {
|
||||||
validate(_args: { token: string; window: number }) { return totpValidateMock(); }
|
validate(_args: { token: string; window: number }) {
|
||||||
toString() { return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET"; }
|
return totpValidateMock();
|
||||||
|
}
|
||||||
|
toString() {
|
||||||
|
return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { Secret, TOTP };
|
return { Secret, TOTP };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── rate-limit mock ──────────────────────────────────────────────────────────
|
// ─── rate-limit mock ──────────────────────────────────────────────────────────
|
||||||
// Default: rate limit allows all requests. Override in specific tests.
|
// Default: rate limit allows all requests. Override in specific tests.
|
||||||
const totpRateLimiterMock = vi.hoisted(() => vi.fn(async (_key: string) => ({
|
const totpRateLimiterMock = vi.hoisted(() =>
|
||||||
|
vi.fn(async (_key: string) => ({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
remaining: 9,
|
remaining: 9,
|
||||||
resetAt: new Date(Date.now() + 30_000),
|
resetAt: new Date(Date.now() + 30_000),
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock("../middleware/rate-limit.js", () => ({
|
vi.mock("../middleware/rate-limit.js", () => ({
|
||||||
totpRateLimiter: totpRateLimiterMock,
|
totpRateLimiter: totpRateLimiterMock,
|
||||||
@@ -85,6 +95,7 @@ function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
|||||||
db: {
|
db: {
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
...((dbOverrides.user as object | undefined) ?? {}),
|
...((dbOverrides.user as object | undefined) ?? {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -94,7 +105,9 @@ function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
|||||||
// ─── generateTotpSecret ───────────────────────────────────────────────────────
|
// ─── generateTotpSecret ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("generateTotpSecret", () => {
|
describe("generateTotpSecret", () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); });
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("writes the base32 secret to the user record", async () => {
|
it("writes the base32 secret to the user record", async () => {
|
||||||
const ctx = makeSelfServiceCtx();
|
const ctx = makeSelfServiceCtx();
|
||||||
@@ -134,14 +147,13 @@ describe("verifyAndEnableTotp", () => {
|
|||||||
const ctx = makeSelfServiceCtx({
|
const ctx = makeSelfServiceCtx({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||||
});
|
});
|
||||||
const result = await verifyAndEnableTotp(
|
const result = await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
|
||||||
ctx as Parameters<typeof verifyAndEnableTotp>[0],
|
token: "123456",
|
||||||
{ token: "123456" },
|
});
|
||||||
);
|
|
||||||
expect(result).toEqual({ enabled: true });
|
expect(result).toEqual({ enabled: true });
|
||||||
expect(ctx.db.user.update).toHaveBeenCalledWith({
|
expect(ctx.db.user.update).toHaveBeenCalledWith({
|
||||||
where: { id: "user_1" },
|
where: { id: "user_1" },
|
||||||
data: { totpEnabled: true },
|
data: { totpEnabled: true, lastTotpAt: expect.any(Date) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,7 +190,9 @@ describe("verifyAndEnableTotp", () => {
|
|||||||
const ctx = makeSelfServiceCtx({
|
const ctx = makeSelfServiceCtx({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||||
});
|
});
|
||||||
await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], { token: "123456" });
|
await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
|
||||||
|
token: "123456",
|
||||||
|
});
|
||||||
// Audit entry is fire-and-forget; wait one tick
|
// Audit entry is fire-and-forget; wait one tick
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
||||||
@@ -191,7 +205,11 @@ describe("verifyTotp", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
totpValidateMock.mockReset();
|
totpValidateMock.mockReset();
|
||||||
totpRateLimiterMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date(Date.now() + 30_000) });
|
totpRateLimiterMock.mockResolvedValue({
|
||||||
|
allowed: true,
|
||||||
|
remaining: 9,
|
||||||
|
resetAt: new Date(Date.now() + 30_000),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const mfaUser = { id: "user_1", totpSecret: "TESTBASE32SECRET", totpEnabled: true };
|
const mfaUser = { id: "user_1", totpSecret: "TESTBASE32SECRET", totpEnabled: true };
|
||||||
@@ -214,13 +232,13 @@ describe("verifyTotp", () => {
|
|||||||
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }));
|
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws BAD_REQUEST when user does not have MFA enabled", async () => {
|
it("throws UNAUTHORIZED when user does not have MFA enabled (prevents user enumeration)", async () => {
|
||||||
const ctx = makePublicCtx({
|
const ctx = makePublicCtx({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) },
|
user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
||||||
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." }));
|
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws BAD_REQUEST when user has no TOTP secret (inconsistent state)", async () => {
|
it("throws BAD_REQUEST when user has no TOTP secret (inconsistent state)", async () => {
|
||||||
@@ -233,11 +251,20 @@ describe("verifyTotp", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws TOO_MANY_REQUESTS when rate limit is exceeded", async () => {
|
it("throws TOO_MANY_REQUESTS when rate limit is exceeded", async () => {
|
||||||
totpRateLimiterMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date(Date.now() + 30_000) });
|
totpRateLimiterMock.mockResolvedValue({
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetAt: new Date(Date.now() + 30_000),
|
||||||
|
});
|
||||||
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
||||||
await expect(
|
await expect(
|
||||||
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
||||||
).rejects.toThrow(new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." }));
|
).rejects.toThrow(
|
||||||
|
new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many TOTP attempts. Please wait before trying again.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not check the token when rate limit is exceeded", async () => {
|
it("does not check the token when rate limit is exceeded", async () => {
|
||||||
@@ -253,7 +280,10 @@ describe("verifyTotp", () => {
|
|||||||
it("calls the rate limiter with the userId as key", async () => {
|
it("calls the rate limiter with the userId as key", async () => {
|
||||||
totpValidateMock.mockReturnValue(0);
|
totpValidateMock.mockReturnValue(0);
|
||||||
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
||||||
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" });
|
await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
|
||||||
|
userId: "user_1",
|
||||||
|
token: "123456",
|
||||||
|
});
|
||||||
expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1");
|
expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -261,7 +291,9 @@ describe("verifyTotp", () => {
|
|||||||
// ─── getCurrentMfaStatus ──────────────────────────────────────────────────────
|
// ─── getCurrentMfaStatus ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getCurrentMfaStatus", () => {
|
describe("getCurrentMfaStatus", () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); });
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns totpEnabled: true when MFA is active", async () => {
|
it("returns totpEnabled: true when MFA is active", async () => {
|
||||||
const ctx = makeSelfServiceCtx({
|
const ctx = makeSelfServiceCtx({
|
||||||
|
|||||||
@@ -52,13 +52,19 @@ import { insightsRouter } from "./insights.js";
|
|||||||
import { scenarioRouter } from "./scenario.js";
|
import { scenarioRouter } from "./scenario.js";
|
||||||
import { allocationRouter } from "./allocation/index.js";
|
import { allocationRouter } from "./allocation/index.js";
|
||||||
import { staffingRouter } from "./staffing.js";
|
import { staffingRouter } from "./staffing.js";
|
||||||
import { advancedTimelineToolDefinitions, createAdvancedTimelineExecutors } from "./assistant-tools/advanced-timeline.js";
|
import {
|
||||||
|
advancedTimelineToolDefinitions,
|
||||||
|
createAdvancedTimelineExecutors,
|
||||||
|
} from "./assistant-tools/advanced-timeline.js";
|
||||||
import {
|
import {
|
||||||
allocationPlanningMutationToolDefinitions,
|
allocationPlanningMutationToolDefinitions,
|
||||||
allocationPlanningReadToolDefinitions,
|
allocationPlanningReadToolDefinitions,
|
||||||
createAllocationPlanningExecutors,
|
createAllocationPlanningExecutors,
|
||||||
} from "./assistant-tools/allocation-planning.js";
|
} from "./assistant-tools/allocation-planning.js";
|
||||||
import { settingsAdminToolDefinitions, createSettingsAdminExecutors } from "./assistant-tools/settings-admin.js";
|
import {
|
||||||
|
settingsAdminToolDefinitions,
|
||||||
|
createSettingsAdminExecutors,
|
||||||
|
} from "./assistant-tools/settings-admin.js";
|
||||||
import {
|
import {
|
||||||
createVacationHolidayExecutors,
|
createVacationHolidayExecutors,
|
||||||
vacationHolidayMutationToolDefinitions,
|
vacationHolidayMutationToolDefinitions,
|
||||||
@@ -184,36 +190,84 @@ export const MUTATION_TOOLS = new Set([
|
|||||||
"cancel_dispo_import_batch",
|
"cancel_dispo_import_batch",
|
||||||
"resolve_dispo_staged_record",
|
"resolve_dispo_staged_record",
|
||||||
"commit_dispo_import_batch",
|
"commit_dispo_import_batch",
|
||||||
"create_allocation", "cancel_allocation", "update_allocation_status",
|
"create_allocation",
|
||||||
"update_timeline_allocation_inline", "apply_timeline_project_shift",
|
"cancel_allocation",
|
||||||
"quick_assign_timeline_resource", "batch_quick_assign_timeline_resources",
|
"update_allocation_status",
|
||||||
|
"update_timeline_allocation_inline",
|
||||||
|
"apply_timeline_project_shift",
|
||||||
|
"quick_assign_timeline_resource",
|
||||||
|
"batch_quick_assign_timeline_resources",
|
||||||
"batch_shift_timeline_allocations",
|
"batch_shift_timeline_allocations",
|
||||||
"update_resource", "deactivate_resource", "create_resource",
|
"update_resource",
|
||||||
"update_project", "create_project", "delete_project",
|
"deactivate_resource",
|
||||||
"create_vacation", "approve_vacation", "reject_vacation", "cancel_vacation",
|
"create_resource",
|
||||||
"set_entitlement", "create_demand", "fill_demand",
|
"update_project",
|
||||||
"generate_project_cover", "remove_project_cover",
|
"create_project",
|
||||||
"create_role", "update_role", "delete_role",
|
"delete_project",
|
||||||
"create_client", "update_client", "delete_client",
|
"create_vacation",
|
||||||
"create_org_unit", "update_org_unit",
|
"approve_vacation",
|
||||||
"create_country", "update_country",
|
"reject_vacation",
|
||||||
"create_metro_city", "update_metro_city", "delete_metro_city",
|
"cancel_vacation",
|
||||||
"create_holiday_calendar", "update_holiday_calendar", "delete_holiday_calendar",
|
"set_entitlement",
|
||||||
"create_holiday_calendar_entry", "update_holiday_calendar_entry", "delete_holiday_calendar_entry",
|
"create_demand",
|
||||||
"send_broadcast", "create_task_for_user", "create_reminder",
|
"fill_demand",
|
||||||
"update_task_status", "execute_task_action",
|
"generate_project_cover",
|
||||||
"create_comment", "resolve_comment", "mark_notification_read",
|
"remove_project_cover",
|
||||||
"save_dashboard_layout", "toggle_favorite_project",
|
"create_role",
|
||||||
"set_column_preferences", "generate_totp_secret", "verify_and_enable_totp",
|
"update_role",
|
||||||
"create_user", "set_user_password", "update_user_role", "update_user_name",
|
"delete_role",
|
||||||
"link_user_resource", "auto_link_users_by_email", "set_user_permissions",
|
"create_client",
|
||||||
"reset_user_permissions", "disable_user_totp",
|
"update_client",
|
||||||
"create_notification", "update_reminder", "delete_reminder",
|
"delete_client",
|
||||||
"delete_notification", "assign_task",
|
"create_org_unit",
|
||||||
"clone_estimate", "update_estimate_draft", "submit_estimate_version",
|
"update_org_unit",
|
||||||
"approve_estimate_version", "create_estimate_revision",
|
"create_country",
|
||||||
"create_estimate_export", "create_estimate_planning_handoff",
|
"update_country",
|
||||||
"generate_estimate_weekly_phasing", "update_estimate_commercial_terms",
|
"create_metro_city",
|
||||||
|
"update_metro_city",
|
||||||
|
"delete_metro_city",
|
||||||
|
"create_holiday_calendar",
|
||||||
|
"update_holiday_calendar",
|
||||||
|
"delete_holiday_calendar",
|
||||||
|
"create_holiday_calendar_entry",
|
||||||
|
"update_holiday_calendar_entry",
|
||||||
|
"delete_holiday_calendar_entry",
|
||||||
|
"send_broadcast",
|
||||||
|
"create_task_for_user",
|
||||||
|
"create_reminder",
|
||||||
|
"update_task_status",
|
||||||
|
"execute_task_action",
|
||||||
|
"create_comment",
|
||||||
|
"resolve_comment",
|
||||||
|
"mark_notification_read",
|
||||||
|
"save_dashboard_layout",
|
||||||
|
"toggle_favorite_project",
|
||||||
|
"set_column_preferences",
|
||||||
|
"generate_totp_secret",
|
||||||
|
"verify_and_enable_totp",
|
||||||
|
"create_user",
|
||||||
|
"set_user_password",
|
||||||
|
"update_user_role",
|
||||||
|
"update_user_name",
|
||||||
|
"link_user_resource",
|
||||||
|
"auto_link_users_by_email",
|
||||||
|
"set_user_permissions",
|
||||||
|
"reset_user_permissions",
|
||||||
|
"disable_user_totp",
|
||||||
|
"create_notification",
|
||||||
|
"update_reminder",
|
||||||
|
"delete_reminder",
|
||||||
|
"delete_notification",
|
||||||
|
"assign_task",
|
||||||
|
"clone_estimate",
|
||||||
|
"update_estimate_draft",
|
||||||
|
"submit_estimate_version",
|
||||||
|
"approve_estimate_version",
|
||||||
|
"create_estimate_revision",
|
||||||
|
"create_estimate_export",
|
||||||
|
"create_estimate_planning_handoff",
|
||||||
|
"generate_estimate_weekly_phasing",
|
||||||
|
"update_estimate_commercial_terms",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ADVANCED_ASSISTANT_TOOLS = new Set([
|
export const ADVANCED_ASSISTANT_TOOLS = new Set([
|
||||||
@@ -281,7 +335,9 @@ class AssistantVisibleError extends Error {
|
|||||||
|
|
||||||
function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
|
function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
|
||||||
if (!ctx.permissions.has(perm)) {
|
if (!ctx.permissions.has(perm)) {
|
||||||
throw new AssistantVisibleError(`Permission denied: you need the "${perm}" permission to perform this action.`);
|
throw new AssistantVisibleError(
|
||||||
|
`Permission denied: you need the "${perm}" permission to perform this action.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,14 +438,16 @@ function createUtcDate(year: number, monthIndex: number, day: number): Date {
|
|||||||
return new Date(Date.UTC(year, monthIndex, day));
|
return new Date(Date.UTC(year, monthIndex, day));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHolidayPeriod(input: {
|
function resolveHolidayPeriod(input: { year?: number; periodStart?: string; periodEnd?: string }): {
|
||||||
year?: number;
|
year: number | null;
|
||||||
periodStart?: string;
|
periodStart: Date;
|
||||||
periodEnd?: string;
|
periodEnd: Date;
|
||||||
}): { year: number | null; periodStart: Date; periodEnd: Date } {
|
} {
|
||||||
if (input.periodStart || input.periodEnd) {
|
if (input.periodStart || input.periodEnd) {
|
||||||
if (!input.periodStart || !input.periodEnd) {
|
if (!input.periodStart || !input.periodEnd) {
|
||||||
throw new AssistantVisibleError("periodStart and periodEnd must both be provided when using a custom holiday range.");
|
throw new AssistantVisibleError(
|
||||||
|
"periodStart and periodEnd must both be provided when using a custom holiday range.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`);
|
const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`);
|
||||||
@@ -440,14 +498,18 @@ const ASSISTANT_VACATION_REQUEST_TYPES = [
|
|||||||
function parseAssistantVacationRequestType(input: string): VacationType {
|
function parseAssistantVacationRequestType(input: string): VacationType {
|
||||||
const normalized = input.trim().toUpperCase();
|
const normalized = input.trim().toUpperCase();
|
||||||
if (normalized === VacationType.PUBLIC_HOLIDAY) {
|
if (normalized === VacationType.PUBLIC_HOLIDAY) {
|
||||||
throw new AssistantVisibleError("PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead.");
|
throw new AssistantVisibleError(
|
||||||
|
"PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) {
|
if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) {
|
||||||
return normalized as VacationType;
|
return normalized as VacationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new AssistantVisibleError(`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`);
|
throw new AssistantVisibleError(
|
||||||
|
`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIsoDate(value: string, fieldName: string): Date {
|
function parseIsoDate(value: string, fieldName: string): Date {
|
||||||
@@ -458,10 +520,7 @@ function parseIsoDate(value: string, fieldName: string): Date {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionalIsoDate(
|
function parseOptionalIsoDate(value: string | undefined, fieldName: string): Date | undefined {
|
||||||
value: string | undefined,
|
|
||||||
fieldName: string,
|
|
||||||
): Date | undefined {
|
|
||||||
return value ? parseIsoDate(value, fieldName) : undefined;
|
return value ? parseIsoDate(value, fieldName) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,10 +532,7 @@ function parseDateTime(value: string, fieldName: string): Date {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionalDateTime(
|
function parseOptionalDateTime(value: string | undefined, fieldName: string): Date | undefined {
|
||||||
value: string | undefined,
|
|
||||||
fieldName: string,
|
|
||||||
): Date | undefined {
|
|
||||||
return value ? parseDateTime(value, fieldName) : undefined;
|
return value ? parseDateTime(value, fieldName) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,9 +565,7 @@ function toAssistantNotFoundError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantAllocationNotFoundError(
|
function toAssistantAllocationNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
if (error.code === "NOT_FOUND") {
|
if (error.code === "NOT_FOUND") {
|
||||||
return { error: "Allocation not found with the given criteria." };
|
return { error: "Allocation not found with the given criteria." };
|
||||||
@@ -533,10 +587,7 @@ function toAssistantProjectNotFoundError(
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
): AssistantToolErrorResult | null {
|
): AssistantToolErrorResult | null {
|
||||||
return toAssistantNotFoundError(
|
return toAssistantNotFoundError(error, `Project not found: ${identifier}`);
|
||||||
error,
|
|
||||||
`Project not found: ${identifier}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantTimelineMutationError(
|
function toAssistantTimelineMutationError(
|
||||||
@@ -591,13 +642,8 @@ function toAssistantTimelineMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantVacationNotFoundError(
|
function toAssistantVacationNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Vacation not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Vacation not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantVacationMutationError(
|
function toAssistantVacationMutationError(
|
||||||
@@ -665,18 +711,11 @@ function toAssistantProjectCreationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantDemandNotFoundError(
|
function toAssistantDemandNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Demand not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Demand not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantDemandFillError(
|
function toAssistantDemandFillError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantDemandNotFoundError(error);
|
const notFound = toAssistantDemandNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -689,9 +728,7 @@ function toAssistantDemandFillError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantEstimateNotFoundError(
|
function toAssistantEstimateNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
if (error.message.includes("version")) {
|
if (error.message.includes("version")) {
|
||||||
return { error: "Estimate version not found with the given criteria." };
|
return { error: "Estimate version not found with the given criteria." };
|
||||||
@@ -712,10 +749,10 @@ function toAssistantEstimateReadError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
context === "weeklyPhasing"
|
context === "weeklyPhasing" &&
|
||||||
&& error instanceof TRPCError
|
error instanceof TRPCError &&
|
||||||
&& error.code === "PRECONDITION_FAILED"
|
error.code === "PRECONDITION_FAILED" &&
|
||||||
&& error.message === "Estimate has no versions"
|
error.message === "Estimate has no versions"
|
||||||
) {
|
) {
|
||||||
return { error: "Estimate version not found with the given criteria." };
|
return { error: "Estimate version not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -735,9 +772,7 @@ function toAssistantHolidayCalendarNotFoundError(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantHolidayCalendarMutationError(
|
function toAssistantHolidayCalendarMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantHolidayCalendarNotFoundError(error);
|
const notFound = toAssistantHolidayCalendarNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -754,18 +789,14 @@ function toAssistantHolidayCalendarMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantHolidayEntryNotFoundError(
|
function toAssistantHolidayEntryNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
return toAssistantNotFoundError(
|
||||||
error,
|
error,
|
||||||
"Holiday calendar entry not found with the given criteria.",
|
"Holiday calendar entry not found with the given criteria.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantHolidayEntryMutationError(
|
function toAssistantHolidayEntryMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const calendarNotFound = toAssistantHolidayCalendarNotFoundError(error);
|
const calendarNotFound = toAssistantHolidayCalendarNotFoundError(error);
|
||||||
if (calendarNotFound) {
|
if (calendarNotFound) {
|
||||||
return calendarNotFound;
|
return calendarNotFound;
|
||||||
@@ -783,13 +814,8 @@ function toAssistantHolidayEntryMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantRoleNotFoundError(
|
function toAssistantRoleNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Role not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Role not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantRoleMutationError(
|
function toAssistantRoleMutationError(
|
||||||
@@ -829,19 +855,22 @@ function toAssistantClientMutationError(
|
|||||||
|
|
||||||
if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") {
|
||||||
if (error.message.includes("project")) {
|
if (error.message.includes("project")) {
|
||||||
return { error: "Client cannot be deleted while it still has projects. Deactivate it instead." };
|
return {
|
||||||
|
error: "Client cannot be deleted while it still has projects. Deactivate it instead.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (error.message.includes("child client")) {
|
if (error.message.includes("child client")) {
|
||||||
return { error: "Client cannot be deleted while it still has child clients. Remove or reassign them first." };
|
return {
|
||||||
|
error:
|
||||||
|
"Client cannot be deleted while it still has child clients. Remove or reassign them first.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantOrgUnitNotFoundError(
|
function toAssistantOrgUnitNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
if (error.message.includes("Parent org unit")) {
|
if (error.message.includes("Parent org unit")) {
|
||||||
return { error: "Parent org unit not found with the given criteria." };
|
return { error: "Parent org unit not found with the given criteria." };
|
||||||
@@ -852,9 +881,7 @@ function toAssistantOrgUnitNotFoundError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantOrgUnitMutationError(
|
function toAssistantOrgUnitMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantOrgUnitNotFoundError(error);
|
const notFound = toAssistantOrgUnitNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -869,9 +896,7 @@ function toAssistantOrgUnitMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantCountryNotFoundError(
|
function toAssistantCountryNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Country not found with the given criteria." };
|
return { error: "Country not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -879,9 +904,7 @@ function toAssistantCountryNotFoundError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantCountryMutationError(
|
function toAssistantCountryMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantCountryNotFoundError(error);
|
const notFound = toAssistantCountryNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -894,9 +917,7 @@ function toAssistantCountryMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantResourceCreationError(
|
function toAssistantResourceCreationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
if (error.code === "CONFLICT") {
|
if (error.code === "CONFLICT") {
|
||||||
return { error: "A resource with this EID or email already exists." };
|
return { error: "A resource with this EID or email already exists." };
|
||||||
@@ -935,16 +956,18 @@ function toAssistantResourceCreationError(
|
|||||||
if (errorText.includes("country")) {
|
if (errorText.includes("country")) {
|
||||||
return { error: "Country not found with the given criteria." };
|
return { error: "Country not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) {
|
if (
|
||||||
|
errorText.includes("orgunit") ||
|
||||||
|
errorText.includes("org_unit") ||
|
||||||
|
errorText.includes("org unit")
|
||||||
|
) {
|
||||||
return { error: "Org unit not found with the given criteria." };
|
return { error: "Org unit not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: "The selected role, country, or org unit no longer exists." };
|
return { error: "The selected role, country, or org unit no longer exists." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantResourceMutationError(
|
function toAssistantResourceMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -969,16 +992,18 @@ function toAssistantResourceMutationError(
|
|||||||
if (errorText.includes("country")) {
|
if (errorText.includes("country")) {
|
||||||
return { error: "Country not found with the given criteria." };
|
return { error: "Country not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) {
|
if (
|
||||||
|
errorText.includes("orgunit") ||
|
||||||
|
errorText.includes("org_unit") ||
|
||||||
|
errorText.includes("org unit")
|
||||||
|
) {
|
||||||
return { error: "Org unit not found with the given criteria." };
|
return { error: "Org unit not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantProjectMutationError(
|
function toAssistantProjectMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Project not found with the given criteria." };
|
return { error: "Project not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -1007,9 +1032,7 @@ function toAssistantProjectMutationError(
|
|||||||
return { error: "Project not found with the given criteria." };
|
return { error: "Project not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantMetroCityMutationError(
|
function toAssistantMetroCityMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
if (error.message.includes("Country")) {
|
if (error.message.includes("Country")) {
|
||||||
return { error: "Country not found with the given criteria." };
|
return { error: "Country not found with the given criteria." };
|
||||||
@@ -1024,9 +1047,7 @@ function toAssistantMetroCityMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantDemandCreationError(
|
function toAssistantDemandCreationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
if (error.message.includes("Role")) {
|
if (error.message.includes("Role")) {
|
||||||
return { error: "Role not found with the given criteria." };
|
return { error: "Role not found with the given criteria." };
|
||||||
@@ -1054,9 +1075,7 @@ function toAssistantDemandCreationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantVacationCreationError(
|
function toAssistantVacationCreationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
if (error.code === "FORBIDDEN") {
|
if (error.code === "FORBIDDEN") {
|
||||||
return { error: "You can only create vacation requests for your own resource." };
|
return { error: "You can only create vacation requests for your own resource." };
|
||||||
@@ -1088,9 +1107,7 @@ function toAssistantVacationCreationError(
|
|||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantEntitlementMutationError(
|
function toAssistantEntitlementMutationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -1111,9 +1128,7 @@ function toAssistantEntitlementMutationError(
|
|||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantEstimateCreationError(
|
function toAssistantEstimateCreationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Project not found with the given criteria." };
|
return { error: "Project not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -1137,11 +1152,17 @@ function toAssistantEstimateCreationError(
|
|||||||
if (errorText.includes("resource")) {
|
if (errorText.includes("resource")) {
|
||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
|
if (
|
||||||
|
errorText.includes("scopeitem") ||
|
||||||
|
errorText.includes("scope_item") ||
|
||||||
|
errorText.includes("scope item")
|
||||||
|
) {
|
||||||
return { error: "Estimate scope item not found with the given criteria." };
|
return { error: "Estimate scope item not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
|
return {
|
||||||
|
error: "One of the referenced project, role, resource, or scope items no longer exists.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantEstimateMutationError(
|
function toAssistantEstimateMutationError(
|
||||||
@@ -1201,7 +1222,9 @@ function toAssistantEstimateMutationError(
|
|||||||
return { error: "Commercial terms can only be edited on working versions." };
|
return { error: "Commercial terms can only be edited on working versions." };
|
||||||
default:
|
default:
|
||||||
if (error.message.startsWith("Project window has no working days for demand line")) {
|
if (error.message.startsWith("Project window has no working days for demand line")) {
|
||||||
return { error: "The linked project window has no working days for at least one demand line." };
|
return {
|
||||||
|
error: "The linked project window has no working days for at least one demand line.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1227,18 +1250,32 @@ function toAssistantEstimateMutationError(
|
|||||||
if (errorText.includes("resource")) {
|
if (errorText.includes("resource")) {
|
||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
|
if (
|
||||||
|
errorText.includes("scopeitem") ||
|
||||||
|
errorText.includes("scope_item") ||
|
||||||
|
errorText.includes("scope item")
|
||||||
|
) {
|
||||||
return { error: "Estimate scope item not found with the given criteria." };
|
return { error: "Estimate scope item not found with the given criteria." };
|
||||||
}
|
}
|
||||||
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
|
return {
|
||||||
|
error: "One of the referenced project, role, resource, or scope items no longer exists.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prismaError.code === "P2025") {
|
if (prismaError.code === "P2025") {
|
||||||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||||||
if (errorText.includes("estimatedemandline") || errorText.includes("estimate_demand_line") || errorText.includes("estimate demand line")) {
|
if (
|
||||||
|
errorText.includes("estimatedemandline") ||
|
||||||
|
errorText.includes("estimate_demand_line") ||
|
||||||
|
errorText.includes("estimate demand line")
|
||||||
|
) {
|
||||||
return { error: "Estimate demand line not found with the given criteria." };
|
return { error: "Estimate demand line not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (errorText.includes("estimateversion") || errorText.includes("estimate_version") || errorText.includes("estimate version")) {
|
if (
|
||||||
|
errorText.includes("estimateversion") ||
|
||||||
|
errorText.includes("estimate_version") ||
|
||||||
|
errorText.includes("estimate version")
|
||||||
|
) {
|
||||||
return { error: "Estimate version not found with the given criteria." };
|
return { error: "Estimate version not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (errorText.includes("estimate")) {
|
if (errorText.includes("estimate")) {
|
||||||
@@ -1278,7 +1315,7 @@ function toAssistantUserMutationError(
|
|||||||
for (const issue of validationIssues) {
|
for (const issue of validationIssues) {
|
||||||
const field = issue.path[0];
|
const field = issue.path[0];
|
||||||
if (field === "password" && issue.code === "too_small") {
|
if (field === "password" && issue.code === "too_small") {
|
||||||
return { error: "Password must be at least 8 characters." };
|
return { error: "Password must be at least 12 characters." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field === "name" && issue.code === "too_small") {
|
if (field === "name" && issue.code === "too_small") {
|
||||||
@@ -1290,8 +1327,8 @@ function toAssistantUserMutationError(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes("Password must be at least 8 characters")) {
|
if (error.message.includes("Password must be at least 12 characters")) {
|
||||||
return { error: "Password must be at least 8 characters." };
|
return { error: "Password must be at least 12 characters." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.message.includes("Name is required")) {
|
if (error.message.includes("Name is required")) {
|
||||||
@@ -1325,8 +1362,11 @@ function getTrpcValidationIssues(error: TRPCError): Array<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
.filter((issue): issue is { code?: unknown; path?: unknown } => issue !== null && typeof issue === "object")
|
.filter(
|
||||||
.map((issue) => (
|
(issue): issue is { code?: unknown; path?: unknown } =>
|
||||||
|
issue !== null && typeof issue === "object",
|
||||||
|
)
|
||||||
|
.map((issue) =>
|
||||||
typeof issue.code === "string"
|
typeof issue.code === "string"
|
||||||
? {
|
? {
|
||||||
code: issue.code,
|
code: issue.code,
|
||||||
@@ -1334,16 +1374,14 @@ function getTrpcValidationIssues(error: TRPCError): Array<{
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [],
|
path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [],
|
||||||
}
|
},
|
||||||
));
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantUserResourceLinkError(
|
function toAssistantUserResourceLinkError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||||||
if (error.message.includes("already linked")) {
|
if (error.message.includes("already linked")) {
|
||||||
return { error: "Resource is already linked to another user." };
|
return { error: "Resource is already linked to another user." };
|
||||||
@@ -1367,13 +1405,11 @@ function toAssistantUserResourceLinkError(
|
|||||||
|
|
||||||
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
|
||||||
const pointsToUser =
|
const pointsToUser =
|
||||||
errorText.includes("userid")
|
errorText.includes("userid") || errorText.includes("user_id") || errorText.includes(" user ");
|
||||||
|| errorText.includes("user_id")
|
|
||||||
|| errorText.includes(" user ");
|
|
||||||
const pointsToResource =
|
const pointsToResource =
|
||||||
errorText.includes("resourceid")
|
errorText.includes("resourceid") ||
|
||||||
|| errorText.includes("resource_id")
|
errorText.includes("resource_id") ||
|
||||||
|| errorText.includes(" resource ");
|
errorText.includes(" resource ");
|
||||||
|
|
||||||
if (prismaError.code === "P2025") {
|
if (prismaError.code === "P2025") {
|
||||||
return { error: "Resource not found with the given criteria." };
|
return { error: "Resource not found with the given criteria." };
|
||||||
@@ -1392,9 +1428,7 @@ function toAssistantUserResourceLinkError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantTotpEnableError(
|
function toAssistantTotpEnableError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
if (error.message.includes("No TOTP secret generated")) {
|
if (error.message.includes("No TOTP secret generated")) {
|
||||||
return { error: "No TOTP secret generated. Call generate_totp_secret first." };
|
return { error: "No TOTP secret generated. Call generate_totp_secret first." };
|
||||||
@@ -1419,13 +1453,8 @@ function toAssistantTotpEnableError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantWebhookNotFoundError(
|
function toAssistantWebhookNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Webhook not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Webhook not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantWebhookMutationError(
|
function toAssistantWebhookMutationError(
|
||||||
@@ -1439,9 +1468,7 @@ function toAssistantWebhookMutationError(
|
|||||||
|
|
||||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
return {
|
return {
|
||||||
error: action === "create"
|
error: action === "create" ? "Webhook input is invalid." : "Webhook update input is invalid.",
|
||||||
? "Webhook input is invalid."
|
|
||||||
: "Webhook update input is invalid.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1453,27 +1480,15 @@ function toAssistantWebhookMutationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantAuditLogEntryNotFoundError(
|
function toAssistantAuditLogEntryNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Audit log entry not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Audit log entry not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantTaskNotFoundError(
|
function toAssistantTaskNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Task not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Task not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantTaskActionError(
|
function toAssistantTaskActionError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantTaskNotFoundError(error);
|
const notFound = toAssistantTaskNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -1492,14 +1507,19 @@ function toAssistantTaskActionError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
if (error.message.includes("Invalid taskAction format") || error.message.includes("Unknown action")) {
|
if (
|
||||||
|
error.message.includes("Invalid taskAction format") ||
|
||||||
|
error.message.includes("Unknown action")
|
||||||
|
) {
|
||||||
return { error: "Task action is invalid and cannot be executed." };
|
return { error: "Task action is invalid and cannot be executed." };
|
||||||
}
|
}
|
||||||
if (error.message === "Vacation not found") {
|
if (error.message === "Vacation not found") {
|
||||||
return { error: "Vacation not found with the given criteria." };
|
return { error: "Vacation not found with the given criteria." };
|
||||||
}
|
}
|
||||||
if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) {
|
if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) {
|
||||||
return { error: "Vacation is not pending and cannot be approved or rejected via this task action." };
|
return {
|
||||||
|
error: "Vacation is not pending and cannot be approved or rejected via this task action.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (error.message === "Assignment not found") {
|
if (error.message === "Assignment not found") {
|
||||||
return { error: "Assignment not found with the given criteria." };
|
return { error: "Assignment not found with the given criteria." };
|
||||||
@@ -1517,9 +1537,7 @@ function toAssistantTaskActionError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantTaskAssignmentError(
|
function toAssistantTaskAssignmentError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantTaskNotFoundError(error);
|
const notFound = toAssistantTaskNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -1540,36 +1558,19 @@ function toAssistantTaskAssignmentError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantBroadcastNotFoundError(
|
function toAssistantBroadcastNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Broadcast not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Broadcast not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantDispoImportBatchNotFoundError(
|
function toAssistantDispoImportBatchNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Import batch not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Import batch not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantReminderNotFoundError(
|
function toAssistantReminderNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
return toAssistantNotFoundError(error, "Reminder not found with the given criteria.");
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotFoundError(
|
|
||||||
error,
|
|
||||||
"Reminder not found with the given criteria.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantNotificationNotFoundError(
|
function toAssistantNotificationNotFoundError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Notification not found with the given criteria." };
|
return { error: "Notification not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -1582,15 +1583,11 @@ function toAssistantNotificationNotFoundError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantNotificationReadError(
|
function toAssistantNotificationReadError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
return toAssistantNotificationNotFoundError(error);
|
return toAssistantNotificationNotFoundError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantNotificationDeletionError(
|
function toAssistantNotificationDeletionError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
const notFound = toAssistantNotificationNotFoundError(error);
|
const notFound = toAssistantNotificationNotFoundError(error);
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return notFound;
|
return notFound;
|
||||||
@@ -1603,9 +1600,7 @@ function toAssistantNotificationDeletionError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantReminderCreationError(
|
function toAssistantReminderCreationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
return { error: "Reminder input is invalid." };
|
return { error: "Reminder input is invalid." };
|
||||||
}
|
}
|
||||||
@@ -1627,9 +1622,7 @@ function toAssistantReminderCreationError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantCommentResolveError(
|
function toAssistantCommentResolveError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
return { error: "Comment not found with the given criteria." };
|
return { error: "Comment not found with the given criteria." };
|
||||||
}
|
}
|
||||||
@@ -1641,9 +1634,7 @@ function toAssistantCommentResolveError(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantCommentCreationError(
|
function toAssistantCommentCreationError(error: unknown): AssistantToolErrorResult | null {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult | null {
|
|
||||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||||
if (error.message.includes("at least 1 character")) {
|
if (error.message.includes("at least 1 character")) {
|
||||||
return { error: "Comment body is required." };
|
return { error: "Comment body is required." };
|
||||||
@@ -1778,14 +1769,16 @@ function getTrpcErrorMetadata(error: unknown): {
|
|||||||
shape?: { code?: unknown; message?: unknown; data?: { cause?: unknown } };
|
shape?: { code?: unknown; message?: unknown; data?: { cause?: unknown } };
|
||||||
};
|
};
|
||||||
|
|
||||||
const candidateCode = typeof candidate.code === "string"
|
const candidateCode =
|
||||||
|
typeof candidate.code === "string"
|
||||||
? candidate.code
|
? candidate.code
|
||||||
: typeof candidate.data?.code === "string"
|
: typeof candidate.data?.code === "string"
|
||||||
? candidate.data.code
|
? candidate.data.code
|
||||||
: typeof candidate.shape?.code === "string"
|
: typeof candidate.shape?.code === "string"
|
||||||
? candidate.shape.code
|
? candidate.shape.code
|
||||||
: null;
|
: null;
|
||||||
const candidateMessage = typeof candidate.message === "string"
|
const candidateMessage =
|
||||||
|
typeof candidate.message === "string"
|
||||||
? candidate.message
|
? candidate.message
|
||||||
: typeof candidate.shape?.message === "string"
|
: typeof candidate.shape?.message === "string"
|
||||||
? candidate.shape.message
|
? candidate.shape.message
|
||||||
@@ -1816,17 +1809,17 @@ function toAssistantNotificationCreationError(
|
|||||||
const trpcError = getTrpcErrorMetadata(error);
|
const trpcError = getTrpcErrorMetadata(error);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
context === "broadcast"
|
context === "broadcast" &&
|
||||||
&& trpcError?.code === "BAD_REQUEST"
|
trpcError?.code === "BAD_REQUEST" &&
|
||||||
&& trpcError.message === "No recipients matched the broadcast target."
|
trpcError.message === "No recipients matched the broadcast target."
|
||||||
) {
|
) {
|
||||||
return { error: "No recipients matched the broadcast target." };
|
return { error: "No recipients matched the broadcast target." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
context === "broadcast"
|
context === "broadcast" &&
|
||||||
&& trpcError?.code === "BAD_REQUEST"
|
trpcError?.code === "BAD_REQUEST" &&
|
||||||
&& trpcError.message === "Scheduled broadcasts with task metadata are not supported yet."
|
trpcError.message === "Scheduled broadcasts with task metadata are not supported yet."
|
||||||
) {
|
) {
|
||||||
return { error: "Scheduled broadcasts with task metadata are not supported yet." };
|
return { error: "Scheduled broadcasts with task metadata are not supported yet." };
|
||||||
}
|
}
|
||||||
@@ -1869,7 +1862,10 @@ function toAssistantNotificationCreationError(
|
|||||||
return { error: "Sender user not found with the given criteria." };
|
return { error: "Sender user not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context === "broadcast" && (errorText.includes("notificationbroadcast") || errorText.includes("broadcast"))) {
|
if (
|
||||||
|
context === "broadcast" &&
|
||||||
|
(errorText.includes("notificationbroadcast") || errorText.includes("broadcast"))
|
||||||
|
) {
|
||||||
return { error: "Broadcast not found with the given criteria." };
|
return { error: "Broadcast not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1888,9 +1884,7 @@ function toAssistantNotificationCreationError(
|
|||||||
return { error: "Notification recipient user not found with the given criteria." };
|
return { error: "Notification recipient user not found with the given criteria." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAssistantExecutionError(
|
function normalizeAssistantExecutionError(error: unknown): AssistantToolErrorResult {
|
||||||
error: unknown,
|
|
||||||
): AssistantToolErrorResult {
|
|
||||||
if (error instanceof AssistantVisibleError) {
|
if (error instanceof AssistantVisibleError) {
|
||||||
return { error: error.message };
|
return { error: error.message };
|
||||||
}
|
}
|
||||||
@@ -1925,9 +1919,7 @@ function normalizeAssistantExecutionError(
|
|||||||
return { error: "The tool could not complete due to an unexpected error." };
|
return { error: "The tool could not complete due to an unexpected error." };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAssistantToolErrorResult(
|
function isAssistantToolErrorResult(value: unknown): value is AssistantToolErrorResult {
|
||||||
value: unknown,
|
|
||||||
): value is AssistantToolErrorResult {
|
|
||||||
return value !== null && typeof value === "object" && "error" in value;
|
return value !== null && typeof value === "object" && "error" in value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1961,10 +1953,7 @@ async function resolveEntityOrAssistantError<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveProjectIdentifier(
|
async function resolveProjectIdentifier(ctx: ToolContext, identifier: string) {
|
||||||
ctx: ToolContext,
|
|
||||||
identifier: string,
|
|
||||||
) {
|
|
||||||
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
const caller = createProjectCaller(createScopedCallerContext(ctx));
|
||||||
return resolveEntityOrAssistantError(
|
return resolveEntityOrAssistantError(
|
||||||
() => caller.resolveByIdentifier({ identifier }),
|
() => caller.resolveByIdentifier({ identifier }),
|
||||||
@@ -1972,10 +1961,7 @@ async function resolveProjectIdentifier(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveResourceIdentifier(
|
async function resolveResourceIdentifier(ctx: ToolContext, identifier: string) {
|
||||||
ctx: ToolContext,
|
|
||||||
identifier: string,
|
|
||||||
) {
|
|
||||||
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
const caller = createResourceCaller(createScopedCallerContext(ctx));
|
||||||
return resolveEntityOrAssistantError(
|
return resolveEntityOrAssistantError(
|
||||||
() => caller.resolveByIdentifier({ identifier }),
|
() => caller.resolveByIdentifier({ identifier }),
|
||||||
@@ -2010,7 +1996,8 @@ function sanitizeWebhookList<T extends { secret?: string | null }>(webhooks: T[]
|
|||||||
|
|
||||||
// ─── Tool Definitions ───────────────────────────────────────────────────────
|
// ─── Tool Definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess(
|
||||||
|
[
|
||||||
// ── READ TOOLS ──
|
// ── READ TOOLS ──
|
||||||
...resourceReadToolDefinitions,
|
...resourceReadToolDefinitions,
|
||||||
...projectReadToolDefinitions,
|
...projectReadToolDefinitions,
|
||||||
@@ -2065,7 +2052,9 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
|||||||
...auditHistoryToolDefinitions,
|
...auditHistoryToolDefinitions,
|
||||||
...importExportDispoToolDefinitions,
|
...importExportDispoToolDefinitions,
|
||||||
...settingsAdminToolDefinitions,
|
...settingsAdminToolDefinitions,
|
||||||
], LEGACY_MONOLITHIC_TOOL_ACCESS);
|
],
|
||||||
|
LEGACY_MONOLITHIC_TOOL_ACCESS,
|
||||||
|
);
|
||||||
|
|
||||||
const TOOL_DEFINITIONS_BY_NAME = new Map(
|
const TOOL_DEFINITIONS_BY_NAME = new Map(
|
||||||
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]),
|
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]),
|
||||||
@@ -2081,16 +2070,14 @@ type AssistantToolAccessFailure =
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasAssistantResourceOverviewAccess(
|
function hasAssistantResourceOverviewAccess(permissions: Set<PermissionKey>): boolean {
|
||||||
permissions: Set<PermissionKey>,
|
return (
|
||||||
): boolean {
|
permissions.has(PermissionKey.VIEW_ALL_RESOURCES) ||
|
||||||
return permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|
permissions.has(PermissionKey.MANAGE_RESOURCES)
|
||||||
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAssistantToolAccessRequirements(
|
function getAssistantToolAccessRequirements(tool: ToolDef): ToolAccessRequirements | undefined {
|
||||||
tool: ToolDef,
|
|
||||||
): ToolAccessRequirements | undefined {
|
|
||||||
return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name];
|
return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2104,8 +2091,8 @@ function getAssistantToolAccessFailure(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
access.allowedSystemRoles
|
access.allowedSystemRoles &&
|
||||||
&& !access.allowedSystemRoles.includes(ctx.userRole as SystemRole)
|
!access.allowedSystemRoles.includes(ctx.userRole as SystemRole)
|
||||||
) {
|
) {
|
||||||
return { type: "role" };
|
return { type: "role" };
|
||||||
}
|
}
|
||||||
@@ -2135,8 +2122,8 @@ function getAssistantToolAccessFailure(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
access.requiresAdvancedAssistant
|
access.requiresAdvancedAssistant &&
|
||||||
&& !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)
|
!ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
type: "permission",
|
type: "permission",
|
||||||
@@ -2144,10 +2131,7 @@ function getAssistantToolAccessFailure(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (access.requiresResourceOverview && !hasAssistantResourceOverviewAccess(ctx.permissions)) {
|
||||||
access.requiresResourceOverview
|
|
||||||
&& !hasAssistantResourceOverviewAccess(ctx.permissions)
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
type: "permission",
|
type: "permission",
|
||||||
message: "Permission denied: you need resource overview access to perform this action.",
|
message: "Permission denied: you need resource overview access to perform this action.",
|
||||||
@@ -2157,9 +2141,7 @@ function getAssistantToolAccessFailure(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAssistantToolAccessError(
|
function toAssistantToolAccessError(failure: AssistantToolAccessFailure): AssistantVisibleError {
|
||||||
failure: AssistantToolAccessFailure,
|
|
||||||
): AssistantVisibleError {
|
|
||||||
if (failure.type === "role") {
|
if (failure.type === "role") {
|
||||||
return new AssistantVisibleError("You do not have permission to perform this action.");
|
return new AssistantVisibleError("You do not have permission to perform this action.");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user