d15273e2a6
The operator account is the owner of a placeholder business, so sign-in landed it on that business's empty dashboard with a "Create your first branch" nudge. Operators now land on /platform (sign-in and the root page), the dashboard header says "Platform operator", and the nudge is hidden on the operator's own business. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
8.0 KiB
TypeScript
148 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
|
|
describe("where sign-in lands", () => {
|
|
it("sends the platform operator to the platform console, owners to the dashboard, cashiers to the till", async () => {
|
|
const t = await makeTenant();
|
|
await Promise.all([setPassword(t.owner, "owner-pass"), setPassword(t.cashierA, "cashier-pass")]);
|
|
expect((await json(await signIn(t.owner, "owner-pass"))).body.redirectTo).toBe("/admin/dashboard");
|
|
expect((await json(await signIn(t.cashierA, "cashier-pass"))).body.redirectTo).toBe("/terminal");
|
|
|
|
await db(() => User.updateOne({ _id: t.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
|
|
const operator = { ...t.owner, email: "operator@aquaflow.test" };
|
|
expect((await json(await signIn(operator, "owner-pass"))).body.redirectTo).toBe("/platform");
|
|
});
|
|
});
|