import { describe, expect, it } from "vitest"; import bcrypt from "bcryptjs"; import { User } from "@/lib/server/models/User"; import { Branch } from "@/lib/server/models/Branch"; import { UserSession } from "@/lib/server/models/UserSession"; import * as invite from "@/app/api/users/[id]/invite/route"; import * as invites from "@/app/api/invites/[token]/route"; import * as users from "@/app/api/users/route"; import * as userById from "@/app/api/users/[id]/route"; import * as password from "@/app/api/auth/password/route"; import * as login from "@/app/api/auth/login/route"; import * as ownerReset from "@/app/api/platform/businesses/[id]/owner-reset/route"; import { db, json, makeTenant, params, req, uuid, type Actor } from "./helpers"; const DEVICE = "00000000-0000-4000-8000-000000000001"; // the device tokenFor() signs in on async function setPassword(a: Actor, pw: string) { await db(() => User.updateOne({ _id: a.id }, { passwordHash: bcrypt.hashSync(pw, 4) })); } const signIn = (a: Actor, pw: string) => login.POST(req("POST", "/x", { body: { email: a.email, password: pw, deviceId: uuid() } })); const tokenOf = (url: string) => url.split("/invite/")[1]; const sessions = (a: Actor) => db(() => UserSession.countDocuments({ userId: a.id })); describe("password reset links", () => { it("lets a manager reset their cashier's forgotten password; the old one works until the link is used", async () => { const t = await makeTenant(); await setPassword(t.cashierA, "old-password"); await signIn(t.cashierA, "old-password"); // a session on some phone const r = await json(await invite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierA.id }))); expect(r.status).toBe(200); expect(r.body.purpose).toBe("reset"); expect((await signIn(t.cashierA, "old-password")).status).toBe(200); const page = await json(await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) }))); expect(page.body.purpose).toBe("reset"); const accept = await invites.POST(req("POST", "/x", { body: { password: "new-password" } }), params({ token: tokenOf(r.body.inviteUrl) })); expect(accept.status).toBe(200); expect(await sessions(t.cashierA)).toBe(0); // every device signed out expect((await signIn(t.cashierA, "old-password")).status).toBe(401); expect((await signIn(t.cashierA, "new-password")).status).toBe(200); }); it("does not show a reset as a pending invite", async () => { const t = await makeTenant(); await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id })); const list = await json(await users.GET(req("GET", "/api/users", { as: t.owner }))); expect(list.body.users.find((u: any) => u._id === t.cashierA.id).invitePending).toBe(false); }); it("expires a reset link after 24 hours", async () => { const t = await makeTenant(); const r = await json(await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id }))); const user = await db(() => User.findById(t.cashierA.id).lean()); const hours = (user!.inviteExpiresAt!.getTime() - Date.now()) / 3_600_000; expect(hours).toBeGreaterThan(23.9); expect(hours).toBeLessThanOrEqual(24); await db(() => User.updateOne({ _id: t.cashierA.id }, { inviteExpiresAt: new Date(Date.now() - 1000) })); expect((await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) }))).status).toBe(404); }); it("keeps managers to their own cashiers, and nobody resets themselves", async () => { const t = await makeTenant(); const post = (as: Actor, id: string) => invite.POST(req("POST", "/x", { as }), params({ id })); expect((await post(t.managerA, t.cashierB.id)).status).toBe(403); expect((await post(t.managerA, t.owner.id)).status).toBe(403); expect((await post(t.owner, t.owner.id)).status).toBe(409); expect((await post(t.cashierA, t.cashierB.id)).status).toBe(403); }); it("lets the platform operator give a business owner a reset link, and nobody else", async () => { const t = await makeTenant("Customer Co"); const home = await makeTenant("Operator Co"); await db(() => User.updateOne({ _id: home.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true })); const operator = { ...home.owner, email: "operator@aquaflow.test" }; const ok = await json(await ownerReset.POST(req("POST", "/x", { as: operator }), params({ id: t.businessId }))); expect(ok.status).toBe(200); expect(ok.body).toMatchObject({ purpose: "reset", email: t.owner.email.toLowerCase() }); expect((await ownerReset.POST(req("POST", "/x", { as: t.owner }), params({ id: t.businessId }))).status).toBe(403); }); }); describe("changing your own password", () => { it("needs the current password, then signs out every other device but this one", async () => { const t = await makeTenant(); await setPassword(t.managerA, "current-pass"); await signIn(t.managerA, "current-pass"); // another device await db(() => UserSession.create({ userId: t.managerA.id, deviceId: DEVICE, refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) })); expect(await sessions(t.managerA)).toBe(2); const change = (currentPassword: string, newPassword: string) => password.POST(req("POST", "/x", { as: t.managerA, body: { currentPassword, newPassword } })); expect((await change("wrong-pass", "brand-new-pass")).status).toBe(400); expect((await change("current-pass", "short")).status).toBe(400); expect((await change("current-pass", "brand-new-pass")).status).toBe(200); expect(await sessions(t.managerA)).toBe(1); expect(await db(() => UserSession.exists({ userId: t.managerA.id, deviceId: DEVICE }))).toBeTruthy(); expect((await signIn(t.managerA, "brand-new-pass")).status).toBe(200); }); }); describe("moving staff between branches", () => { it("moves a manager, updates both branches' manager, and signs them out", async () => { const t = await makeTenant(); await db(() => Branch.updateOne({ _id: t.branchA }, { managerId: t.managerA.id })); await db(() => UserSession.create({ userId: t.managerA.id, deviceId: uuid(), refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) })); const res = await userById.PATCH(req("PATCH", "/x", { as: t.owner, body: { branchId: t.branchB } }), params({ id: t.managerA.id })); expect(res.status).toBe(200); const [a, b, mgr] = await Promise.all([ db(() => Branch.findById(t.branchA).lean()), db(() => Branch.findById(t.branchB).lean()), db(() => User.findById(t.managerA.id).lean()), ]); expect(a!.managerId).toBeNull(); expect(b!.managerId!.toString()).toBe(t.managerA.id); expect(mgr!.branchId!.toString()).toBe(t.branchB); expect(await sessions(t.managerA)).toBe(0); }); it("only lets the owner move people, and only to an active branch", async () => { const t = await makeTenant(); const move = (as: Actor, branchId: string) => userById.PATCH(req("PATCH", "/x", { as, body: { branchId } }), params({ id: t.cashierA.id })); expect((await move(t.managerA, t.branchB)).status).toBe(403); await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false })); expect((await move(t.owner, t.branchB)).status).toBe(404); }); });