yiekheng c493101b60 feat(web): password policy, sign-out, dashboard isolation, activity tweaks
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>
2026-05-10 18:46:29 +08:00

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);
});
});