Multi-fix batch from a rapid feedback round: - Password policy mirrors Facebook's documented rule (≥6 chars + mix of letters with numbers/symbols). Centralised in apps/web/src/lib/password-policy.ts; createUserAction, resetUserPasswordAction, the AddUser form, and the row Reset-password flow all use it. CLI scripts/set-password.ts inlines the same check so the bootstrap path stays consistent. - App shell adds a Sign-out button in both the desktop sidebar footer and the mobile drawer footer, with the signed-in username next to it. Layout passes username down alongside role. Theme toggle was removed from the shell per request — operators don't need it in the chrome. - Dashboard stats: getDashboardStats was running findMany on reminders with NO operator filter, so a brand-new user saw global counts from every tenant. Switched to an INNER JOIN on whatsapp_accounts so the card on / only counts this user's reminders. (Counts had been showing '1 / 1 / 3 / 5' to a fresh user — the cross-tenant leak the user flagged.) - /activity drops the All tab and the Clear-history button. Default filter is now Success when no ?filter= is set; Partial keeps fanning into Paused + Failed; Skipped still merges into Archived. - /settings drops the Display name row entirely and only shows the Role row to admins. Layout receives username so the shell can also surface it next to the Sign-out button. - Tests: password-policy.test.ts (11 cases), updated users.test.ts to use policy-compliant passwords + cover letters-only / digits-only rejection, sidebar-footer assertion swapped from theme-toggle to the new Sign-out + username markup. 453 tests green; typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const {
|
|
requireAdminMock,
|
|
findUserMock,
|
|
findManyAdminsMock,
|
|
insertReturningMock,
|
|
updateMock,
|
|
deleteMock,
|
|
checkRateLimitMock,
|
|
revalidateMock,
|
|
} = vi.hoisted(() => ({
|
|
requireAdminMock: vi.fn(),
|
|
findUserMock: vi.fn(),
|
|
findManyAdminsMock: vi.fn(),
|
|
insertReturningMock: vi.fn(),
|
|
updateMock: vi.fn(),
|
|
deleteMock: vi.fn(),
|
|
checkRateLimitMock: vi.fn(),
|
|
revalidateMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth", async () => {
|
|
const actual = await vi.importActual<typeof import("@/lib/auth")>("@/lib/auth");
|
|
return {
|
|
...actual,
|
|
requireAdmin: () => requireAdminMock(),
|
|
};
|
|
});
|
|
vi.mock("@/lib/db", () => ({
|
|
db: {
|
|
query: {
|
|
operators: {
|
|
findFirst: (...a: unknown[]) => findUserMock(...a),
|
|
findMany: (...a: unknown[]) => findManyAdminsMock(...a),
|
|
},
|
|
},
|
|
insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }),
|
|
update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }),
|
|
delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }),
|
|
},
|
|
}));
|
|
vi.mock("@/lib/rate-limit", () => ({
|
|
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
|
|
}));
|
|
vi.mock("next/cache", () => ({ revalidatePath: revalidateMock }));
|
|
vi.mock("next/headers", () => ({
|
|
headers: async () => ({ get: () => "127.0.0.1" }),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
requireAdminMock.mockReset();
|
|
findUserMock.mockReset();
|
|
findManyAdminsMock.mockReset();
|
|
insertReturningMock.mockReset();
|
|
updateMock.mockReset();
|
|
deleteMock.mockReset();
|
|
checkRateLimitMock.mockReset();
|
|
revalidateMock.mockReset();
|
|
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
|
|
});
|
|
|
|
const ADMIN = {
|
|
id: "11111111-1111-1111-1111-111111111111",
|
|
username: "admin",
|
|
role: "admin" as const,
|
|
};
|
|
const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" };
|
|
const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const };
|
|
|
|
import {
|
|
createUserAction,
|
|
setUserRoleAction,
|
|
resetUserPasswordAction,
|
|
deleteUserAction,
|
|
} from "./users";
|
|
|
|
describe("createUserAction", () => {
|
|
it("admin can create a user with role 'user'", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
insertReturningMock.mockResolvedValue([{ id: USER.id }]);
|
|
const r = await createUserAction({
|
|
username: "bob",
|
|
password: "longpw1",
|
|
role: "user",
|
|
});
|
|
expect(r).toEqual({ ok: true, userId: USER.id });
|
|
});
|
|
|
|
it("rejects username/password under length limits", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
const r = await createUserAction({ username: "a", password: "shortpw", role: "user" });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("setUserRoleAction — self-demote guard", () => {
|
|
it("admin demoting themselves is rejected", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(ADMIN);
|
|
const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" });
|
|
expect(r).toEqual({
|
|
ok: false,
|
|
error: "You can't demote your own account.",
|
|
});
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("admin demoting another admin is allowed when others remain", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins
|
|
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
|
|
expect(r).toEqual({ ok: true });
|
|
});
|
|
|
|
it("admin demoting the last remaining admin is rejected", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin
|
|
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/last admin/i);
|
|
});
|
|
});
|
|
|
|
describe("deleteUserAction", () => {
|
|
it("admin deleting themselves is rejected", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(ADMIN);
|
|
const r = await deleteUserAction({ userId: ADMIN.id });
|
|
expect(r).toEqual({ ok: false, error: "You can't delete your own account." });
|
|
expect(deleteMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("admin deleting another user is allowed", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(USER);
|
|
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]);
|
|
const r = await deleteUserAction({ userId: USER.id });
|
|
expect(r).toEqual({ ok: true });
|
|
});
|
|
|
|
it("admin deleting the last admin is rejected", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total
|
|
const r = await deleteUserAction({ userId: OTHER_ADMIN.id });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/last admin/i);
|
|
});
|
|
});
|
|
|
|
describe("resetUserPasswordAction", () => {
|
|
it("admin can reset another user's password", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(USER);
|
|
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" });
|
|
expect(r).toEqual({ ok: true });
|
|
expect(updateMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects too-short passwords", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(USER);
|
|
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects letters-only passwords (no number or symbol)", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(USER);
|
|
const r = await resetUserPasswordAction({
|
|
userId: USER.id,
|
|
newPassword: "abcdefghij",
|
|
});
|
|
expect(r).toEqual({
|
|
ok: false,
|
|
error: "Password must mix letters with numbers or symbols.",
|
|
});
|
|
});
|
|
|
|
it("rejects digits-only passwords", async () => {
|
|
requireAdminMock.mockResolvedValue(ADMIN);
|
|
findUserMock.mockResolvedValue(USER);
|
|
const r = await resetUserPasswordAction({
|
|
userId: USER.id,
|
|
newPassword: "1234567890",
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|