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"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export default [
|
||||
{
|
||||
ignores: [
|
||||
"apps/**",
|
||||
"packages/**",
|
||||
"node_modules/**",
|
||||
".claude/**",
|
||||
"backups/**",
|
||||
|
||||
+16
-10
@@ -31,13 +31,15 @@ describe("assistant user admin password and role errors", () => {
|
||||
|
||||
const result = await executeTool(
|
||||
"set_user_password",
|
||||
JSON.stringify({ userId: "user_missing", password: "secret123" }),
|
||||
JSON.stringify({ userId: "user_missing", password: "SecurePass123!" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
||||
error: "User not found with the given criteria.",
|
||||
}));
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "User not found with the given criteria.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a stable error when resetting a password that is too short", async () => {
|
||||
@@ -56,9 +58,11 @@ describe("assistant user admin password and role errors", () => {
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
||||
error: "Password must be at least 8 characters.",
|
||||
}));
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "Password must be at least 12 characters.",
|
||||
}),
|
||||
);
|
||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -78,8 +82,10 @@ describe("assistant user admin password and role errors", () => {
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
||||
error: "User not found with the given criteria.",
|
||||
}));
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "User not found with the given criteria.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,14 +38,16 @@ describe("assistant user admin tools user create errors", () => {
|
||||
JSON.stringify({
|
||||
email: "peter.parker@example.com",
|
||||
name: "Peter Parker",
|
||||
password: "secret123",
|
||||
password: "SecurePass123!",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
||||
error: "User with this email already exists.",
|
||||
}));
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "User with this email already exists.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a stable error when creating a user without a name", async () => {
|
||||
@@ -63,14 +65,16 @@ describe("assistant user admin tools user create errors", () => {
|
||||
JSON.stringify({
|
||||
email: "miles.morales@example.com",
|
||||
name: "",
|
||||
password: "secret123",
|
||||
password: "SecurePass123!",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
||||
error: "Name is required.",
|
||||
}));
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "Name is required.",
|
||||
}),
|
||||
);
|
||||
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -94,9 +98,11 @@ describe("assistant user admin tools user create errors", () => {
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(expect.objectContaining({
|
||||
error: "Password must be at least 8 characters.",
|
||||
}));
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "Password must be at least 12 characters.",
|
||||
}),
|
||||
);
|
||||
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({
|
||||
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({
|
||||
where: { id: "user_1" },
|
||||
data: { totpEnabled: true },
|
||||
data: { totpEnabled: true, lastTotpAt: expect.any(Date) },
|
||||
});
|
||||
expect(db.auditLog.create).toHaveBeenCalledWith({
|
||||
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 PAST = new Date(Date.now() - 1000);
|
||||
|
||||
function makeDb(overrides: {
|
||||
user?: Partial<Record<string, unknown>>;
|
||||
resetToken?: Partial<Record<string, unknown>>;
|
||||
} = {}) {
|
||||
function makeDb(
|
||||
overrides: {
|
||||
user?: Partial<Record<string, unknown>>;
|
||||
resetToken?: Partial<Record<string, unknown>>;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
update: vi.fn().mockResolvedValue({ id: "user_1" }),
|
||||
...overrides.user,
|
||||
},
|
||||
activeSession: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
passwordResetToken: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
@@ -68,7 +73,10 @@ describe("auth.requestPasswordReset", () => {
|
||||
}),
|
||||
);
|
||||
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" });
|
||||
|
||||
// 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]!;
|
||||
expect(deleteManyOrder).toBeLessThan(createOrder);
|
||||
});
|
||||
@@ -115,13 +124,14 @@ describe("auth.resetPassword", () => {
|
||||
|
||||
const result = await authRouter.createCaller(ctx).resetPassword({
|
||||
token: "valid-token",
|
||||
password: "NewPassword1!",
|
||||
password: "NewPassword12!",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(db.user.update).toHaveBeenCalledWith({
|
||||
where: { email: "user@example.com" },
|
||||
data: { passwordHash: "$argon2id$newhash" },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(db.passwordResetToken.update).toHaveBeenCalledWith({
|
||||
where: { token: "valid-token" },
|
||||
@@ -134,12 +144,12 @@ describe("auth.resetPassword", () => {
|
||||
const ctx = makeCtx(db);
|
||||
|
||||
await expect(
|
||||
authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1!" }),
|
||||
authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1234!" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
const err = await authRouter
|
||||
.createCaller(ctx)
|
||||
.resetPassword({ token: "bad-token", password: "Password1!" })
|
||||
.resetPassword({ token: "bad-token", password: "Password1234!" })
|
||||
.catch((e: TRPCError) => e);
|
||||
expect((err as TRPCError).code).toBe("NOT_FOUND");
|
||||
});
|
||||
@@ -156,7 +166,7 @@ describe("auth.resetPassword", () => {
|
||||
|
||||
const err = await authRouter
|
||||
.createCaller(ctx)
|
||||
.resetPassword({ token: "used-token", password: "Password1!" })
|
||||
.resetPassword({ token: "used-token", password: "Password1234!" })
|
||||
.catch((e: TRPCError) => e);
|
||||
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
||||
expect((err as TRPCError).message).toMatch(/already been used/);
|
||||
@@ -174,7 +184,7 @@ describe("auth.resetPassword", () => {
|
||||
|
||||
const err = await authRouter
|
||||
.createCaller(ctx)
|
||||
.resetPassword({ token: "expired-token", password: "Password1!" })
|
||||
.resetPassword({ token: "expired-token", password: "Password1234!" })
|
||||
.catch((e: TRPCError) => e);
|
||||
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
||||
expect((err as TRPCError).message).toMatch(/expired/);
|
||||
|
||||
@@ -28,6 +28,9 @@ function makeCtx(userRow: Record<string, unknown> | null = null) {
|
||||
findUnique: vi.fn().mockResolvedValue(userRow),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
activeSession: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
} as never,
|
||||
dbUser: { id: "admin_1" },
|
||||
};
|
||||
@@ -38,13 +41,15 @@ const EXISTING_USER = { id: "user_1", name: "Alice", email: "alice@example.com"
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setUserPassword — happy path", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("hashes the new password and updates the DB", async () => {
|
||||
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.objectContaining({
|
||||
where: { id: "user_1" },
|
||||
@@ -57,7 +62,7 @@ describe("setUserPassword — happy path", () => {
|
||||
it("creates an audit entry with summary 'Password reset by admin'", async () => {
|
||||
const { createAuditEntry } = await import("../lib/audit.js");
|
||||
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
|
||||
await Promise.resolve();
|
||||
@@ -68,23 +73,28 @@ describe("setUserPassword — happy path", () => {
|
||||
});
|
||||
|
||||
describe("setUserPassword — not found", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws when the user does not exist", async () => {
|
||||
const ctx = makeCtx(null); // findUnique returns null
|
||||
await expect(
|
||||
setUserPassword(ctx, { userId: "ghost", password: "NewPass123!" }),
|
||||
setUserPassword(ctx, { userId: "ghost", password: "NewPassword123!" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetUserPasswordInputSchema — validation", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects a password shorter than 8 characters", () => {
|
||||
it("rejects a password shorter than 12 characters", () => {
|
||||
const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "short" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -36,11 +36,16 @@ function createContext(
|
||||
describe("user router authorization", () => {
|
||||
it("requires authentication for self-service profile lookups", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.me()).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
@@ -52,11 +57,16 @@ describe("user router authorization", () => {
|
||||
|
||||
it("requires authentication for dashboard layout reads", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.getDashboardLayout()).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
@@ -69,12 +79,17 @@ describe("user router authorization", () => {
|
||||
it("requires authentication for favorite project toggles", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const update = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.toggleFavoriteProject({ projectId: "project_1" })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
@@ -87,15 +102,22 @@ describe("user router authorization", () => {
|
||||
|
||||
it("requires authentication for dashboard layout saves", async () => {
|
||||
const update = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
update,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
update,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.saveDashboardLayout({
|
||||
layout: { version: 2, gridCols: 12, widgets: [] },
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.saveDashboardLayout({
|
||||
layout: { version: 2, gridCols: 12, widgets: [] },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
@@ -105,11 +127,16 @@ describe("user router authorization", () => {
|
||||
|
||||
it("requires authentication for favorite project reads", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.getFavoriteProjectIds()).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
@@ -122,21 +149,28 @@ describe("user router authorization", () => {
|
||||
it("requires authentication for column preference reads and writes", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const update = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.getColumnPreferences()).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
await expect(caller.setColumnPreferences({
|
||||
view: "resources",
|
||||
visible: ["name"],
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.setColumnPreferences({
|
||||
view: "resources",
|
||||
visible: ["name"],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
@@ -147,11 +181,16 @@ describe("user router authorization", () => {
|
||||
|
||||
it("requires authentication for MFA status lookups", async () => {
|
||||
const findUniqueOrThrow = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUniqueOrThrow,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUniqueOrThrow,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.getMfaStatus()).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
@@ -163,11 +202,13 @@ describe("user router authorization", () => {
|
||||
|
||||
it("forbids regular users from listing assignable users", async () => {
|
||||
const findMany = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
}));
|
||||
const caller = createCaller(
|
||||
createContext({
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(caller.listAssignable()).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
@@ -178,14 +219,19 @@ describe("user router authorization", () => {
|
||||
});
|
||||
|
||||
it("allows managers to list assignable users", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([
|
||||
{ id: "user_1", name: "Alice", email: "alice@example.com" },
|
||||
]);
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const findMany = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await caller.listAssignable();
|
||||
|
||||
@@ -202,11 +248,16 @@ describe("user router authorization", () => {
|
||||
|
||||
it("forbids non-admin users from reading effective permissions", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.getEffectivePermissions({ userId: "user_2" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
@@ -219,19 +270,26 @@ describe("user router authorization", () => {
|
||||
it("forbids non-admin users from setting explicit permission overrides", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const update = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.setPermissions({
|
||||
userId: "user_2",
|
||||
overrides: {
|
||||
granted: ["manageProjects"],
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.setPermissions({
|
||||
userId: "user_2",
|
||||
overrides: {
|
||||
granted: ["manageProjects"],
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required",
|
||||
});
|
||||
@@ -243,12 +301,17 @@ describe("user router authorization", () => {
|
||||
it("forbids non-admin users from resetting permission overrides", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const update = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.resetPermissions({ userId: "user_2" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
@@ -261,11 +324,16 @@ describe("user router authorization", () => {
|
||||
|
||||
it("forbids non-admin users from disabling TOTP for other users", async () => {
|
||||
const findUnique = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.disableTotp({ userId: "user_2" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
@@ -280,21 +348,28 @@ describe("user router authorization", () => {
|
||||
const resourceFindUnique = vi.fn();
|
||||
const updateMany = vi.fn();
|
||||
const update = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique: userFindUnique,
|
||||
},
|
||||
resource: {
|
||||
findUnique: resourceFindUnique,
|
||||
updateMany,
|
||||
update,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique: userFindUnique,
|
||||
},
|
||||
resource: {
|
||||
findUnique: resourceFindUnique,
|
||||
updateMany,
|
||||
update,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.linkResource({
|
||||
userId: "user_2",
|
||||
resourceId: "resource_1",
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.linkResource({
|
||||
userId: "user_2",
|
||||
resourceId: "resource_1",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required",
|
||||
});
|
||||
@@ -309,15 +384,20 @@ describe("user router authorization", () => {
|
||||
const userFindMany = vi.fn();
|
||||
const resourceFindFirst = vi.fn();
|
||||
const resourceUpdate = vi.fn();
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findMany: userFindMany,
|
||||
},
|
||||
resource: {
|
||||
findFirst: resourceFindFirst,
|
||||
update: resourceUpdate,
|
||||
},
|
||||
}, { role: SystemRole.MANAGER }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findMany: userFindMany,
|
||||
},
|
||||
resource: {
|
||||
findFirst: resourceFindFirst,
|
||||
update: resourceUpdate,
|
||||
},
|
||||
},
|
||||
{ role: SystemRole.MANAGER },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.autoLinkAllByEmail()).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
@@ -334,21 +414,27 @@ describe("user router authorization", () => {
|
||||
id: "user_1",
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
lastTotpAt: null,
|
||||
});
|
||||
const caller = createCaller(createContext({
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
}, { session: false }));
|
||||
const caller = createCaller(
|
||||
createContext(
|
||||
{
|
||||
user: {
|
||||
findUnique,
|
||||
},
|
||||
},
|
||||
{ session: false },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(caller.verifyTotp({ userId: "user_1", token: "123456" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "TOTP is not enabled for this user.",
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid TOTP token.",
|
||||
});
|
||||
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
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({
|
||||
userId: "missing_user",
|
||||
resourceId: "resource_1",
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.linkResource({
|
||||
userId: "missing_user",
|
||||
resourceId: "resource_1",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
@@ -112,10 +114,12 @@ describe("user.linkResource", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(caller.linkResource({
|
||||
userId: "user_1",
|
||||
resourceId: "missing_resource",
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.linkResource({
|
||||
userId: "user_1",
|
||||
resourceId: "missing_resource",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
@@ -141,10 +145,12 @@ describe("user.linkResource", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(caller.linkResource({
|
||||
userId: "user_1",
|
||||
resourceId: "resource_1",
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.linkResource({
|
||||
userId: "user_1",
|
||||
resourceId: "resource_1",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
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 () => {
|
||||
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
||||
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
||||
const updateMany = vi.fn()
|
||||
const updateMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ count: 1 })
|
||||
.mockResolvedValueOnce({ count: 1 });
|
||||
const caller = createAdminCaller({
|
||||
@@ -224,7 +231,8 @@ describe("user.linkResource", () => {
|
||||
it("returns CONFLICT when the resource link changes between validation and update", async () => {
|
||||
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
|
||||
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
|
||||
const updateMany = vi.fn()
|
||||
const updateMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ count: 0 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
const caller = createAdminCaller({
|
||||
@@ -237,10 +245,12 @@ describe("user.linkResource", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(caller.linkResource({
|
||||
userId: "user_1",
|
||||
resourceId: "resource_1",
|
||||
})).rejects.toMatchObject({
|
||||
await expect(
|
||||
caller.linkResource({
|
||||
userId: "user_1",
|
||||
resourceId: "resource_1",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
message: "Resource link changed during update. Please retry.",
|
||||
});
|
||||
@@ -249,7 +259,9 @@ describe("user.linkResource", () => {
|
||||
|
||||
describe("user admin account management", () => {
|
||||
it("counts users active within the last five minutes", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
||||
const nowSpy = vi
|
||||
.spyOn(Date, "now")
|
||||
.mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
|
||||
const count = vi.fn().mockResolvedValue(4);
|
||||
const caller = createAdminCaller({
|
||||
user: {
|
||||
@@ -279,24 +291,29 @@ describe("user admin account management", () => {
|
||||
email: "alice@example.com",
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const deleteMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const caller = createAdminCaller({
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
activeSession: {
|
||||
deleteMany,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.setPassword({
|
||||
userId: "user_2",
|
||||
password: "secret123",
|
||||
password: "SecurePass123!",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(argon2HashMock).toHaveBeenCalledWith("secret123");
|
||||
expect(argon2HashMock).toHaveBeenCalledWith("SecurePass123!");
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "user_2" },
|
||||
data: { passwordHash: "hashed-secret" },
|
||||
});
|
||||
expect(deleteMany).toHaveBeenCalledWith({ where: { userId: "user_2" } });
|
||||
});
|
||||
|
||||
it("updates the selected user's display name", async () => {
|
||||
@@ -436,6 +453,7 @@ describe("user permission overrides", () => {
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "user_2" },
|
||||
data: { permissionOverrides: overrides },
|
||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -472,6 +490,7 @@ describe("user permission overrides", () => {
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "user_2" },
|
||||
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(update).toHaveBeenCalledWith({
|
||||
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",
|
||||
totpSecret: "MOCKSECRET",
|
||||
totpEnabled: true,
|
||||
lastTotpAt: null,
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const caller = createAdminCaller({
|
||||
user: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -733,7 +755,11 @@ describe("user profile and TOTP self-service", () => {
|
||||
expect(result).toEqual({ valid: true });
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
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",
|
||||
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 () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
dashboardLayout: {
|
||||
widgets: [
|
||||
{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } },
|
||||
],
|
||||
widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }],
|
||||
},
|
||||
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
|
||||
});
|
||||
|
||||
@@ -21,23 +21,33 @@ const totpValidateMock = vi.hoisted(() => vi.fn<() => number | null>());
|
||||
vi.mock("otpauth", () => {
|
||||
class Secret {
|
||||
base32: string;
|
||||
constructor() { this.base32 = "TESTBASE32SECRET"; }
|
||||
static fromBase32(v: string) { return v; }
|
||||
constructor() {
|
||||
this.base32 = "TESTBASE32SECRET";
|
||||
}
|
||||
static fromBase32(v: string) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
class TOTP {
|
||||
validate(_args: { token: string; window: number }) { return totpValidateMock(); }
|
||||
toString() { return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET"; }
|
||||
validate(_args: { token: string; window: number }) {
|
||||
return totpValidateMock();
|
||||
}
|
||||
toString() {
|
||||
return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET";
|
||||
}
|
||||
}
|
||||
return { Secret, TOTP };
|
||||
});
|
||||
|
||||
// ─── rate-limit mock ──────────────────────────────────────────────────────────
|
||||
// Default: rate limit allows all requests. Override in specific tests.
|
||||
const totpRateLimiterMock = vi.hoisted(() => vi.fn(async (_key: string) => ({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: new Date(Date.now() + 30_000),
|
||||
})));
|
||||
const totpRateLimiterMock = vi.hoisted(() =>
|
||||
vi.fn(async (_key: string) => ({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: new Date(Date.now() + 30_000),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../middleware/rate-limit.js", () => ({
|
||||
totpRateLimiter: totpRateLimiterMock,
|
||||
@@ -85,6 +95,7 @@ function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
...((dbOverrides.user as object | undefined) ?? {}),
|
||||
},
|
||||
},
|
||||
@@ -94,7 +105,9 @@ function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
// ─── generateTotpSecret ───────────────────────────────────────────────────────
|
||||
|
||||
describe("generateTotpSecret", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("writes the base32 secret to the user record", async () => {
|
||||
const ctx = makeSelfServiceCtx();
|
||||
@@ -134,14 +147,13 @@ describe("verifyAndEnableTotp", () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||
});
|
||||
const result = await verifyAndEnableTotp(
|
||||
ctx as Parameters<typeof verifyAndEnableTotp>[0],
|
||||
{ token: "123456" },
|
||||
);
|
||||
const result = await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
|
||||
token: "123456",
|
||||
});
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(ctx.db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user_1" },
|
||||
data: { totpEnabled: true },
|
||||
data: { totpEnabled: true, lastTotpAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +190,9 @@ describe("verifyAndEnableTotp", () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
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
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
||||
@@ -191,7 +205,11 @@ describe("verifyTotp", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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 };
|
||||
@@ -214,13 +232,13 @@ describe("verifyTotp", () => {
|
||||
).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({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) },
|
||||
});
|
||||
await expect(
|
||||
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 () => {
|
||||
@@ -233,11 +251,20 @@ describe("verifyTotp", () => {
|
||||
});
|
||||
|
||||
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) } });
|
||||
await expect(
|
||||
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 () => {
|
||||
@@ -253,7 +280,10 @@ describe("verifyTotp", () => {
|
||||
it("calls the rate limiter with the userId as key", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -261,7 +291,9 @@ describe("verifyTotp", () => {
|
||||
// ─── getCurrentMfaStatus ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getCurrentMfaStatus", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns totpEnabled: true when MFA is active", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user