From 9e3e59a8046ca1413de39ba415f4300c4bdfcdb1 Mon Sep 17 00:00:00 2001 From: denisireri Date: Mon, 12 Oct 2026 11:31:45 +0300 Subject: [PATCH] Make email and phone unique per business, not platform-wide One person could not work for two businesses, and signup and staff creation told anyone whether an email or phone was registered anywhere on the platform. - User email and phone are now unique per business: indexes on (businessId, email) and (businessId, phoneNumber). Duplicate checks in signup and branch-manager creation are scoped to the business, so they no longer reveal other businesses' users. - Login finds the email in every business and checks the password against each account. One match signs straight in; several return 300 with the businesses to choose from, and the login page shows a picker. A wrong password returns the same 401 as before and reveals nothing; failures count against each candidate account, and a correct password only resets the accounts it opens. - Platform-operator access now also needs a platformAdmin flag on the account. With per-business emails, an email match alone would have let any owner create a staff account with the operator's email and open the cross-tenant console. The flag is only set by the seed and the migration; removing the email from PLATFORM_ADMIN_EMAILS still revokes. - npm run migrate:identity flags today's operators (while each listed email still matches one account) and syncs the User indexes, dropping the old unique email_1 and phoneNumber_1. Checked against a database in the old shape; safe to re-run. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- docs/PILOT_READINESS.md | 28 ++++- package.json | 3 +- scripts/migrate-per-business-identity.ts | 49 ++++++++ scripts/seed.ts | 17 ++- src/app/api/auth/login/route.ts | 76 +++++++++--- src/app/api/auth/me/route.ts | 4 +- src/app/api/auth/signup/route.ts | 18 +-- src/app/api/branches/route.ts | 11 +- src/app/api/users/route.ts | 2 +- src/app/login/page.tsx | 54 +++++++- src/lib/server/models/User.ts | 20 ++- src/lib/server/platform.ts | 19 ++- tests/identity.test.ts | 152 +++++++++++++++++++++++ 14 files changed, 407 insertions(+), 52 deletions(-) create mode 100644 scripts/migrate-per-business-identity.ts create mode 100644 tests/identity.test.ts diff --git a/.env.example b/.env.example index 96864b1..88851b5 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,10 @@ JWT_REFRESH_TTL=30d DELIVERY_MAX_DEVICES=2 # ---- Platform operator ---- -# Comma-separated emails with cross-tenant access to /platform. Create these -# accounts with `npm run seed`; public signup refuses these addresses. +# Comma-separated emails with cross-tenant access to /platform. The account +# must also carry the platformAdmin flag, set only by `npm run seed` or +# `npm run migrate:identity`. Removing an email here revokes access. +# Public signup refuses these addresses. PLATFORM_ADMIN_EMAILS= # ---- Invite links ---- diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md index f86b090..0ce6bb8 100644 --- a/docs/PILOT_READINESS.md +++ b/docs/PILOT_READINESS.md @@ -131,9 +131,33 @@ prices read-only. mismatch. - The Products page always shows default prices. +## People in more than one business + +Email and phone are unique **within a business**, not across the platform, +so one person can work for two businesses. Each account has its own +password and role. + +- Login looks the email up in every business. If the password matches one + account, it signs straight in; if it matches several, the login page asks + which business. Someone with a wrong password learns nothing about which + businesses an email belongs to, and a wrong guess never resets the + failed-login count of another business's account. +- Signup and staff creation only check for duplicates inside the business, + so they no longer reveal whether an email or phone is registered + elsewhere. +- Platform-operator access now needs the `platformAdmin` flag on the + account as well as the email in `PLATFORM_ADMIN_EMAILS`. Without the + flag, any owner could create a staff account with the operator's email + in their own business. The flag is only set by `npm run seed` and + `npm run migrate:identity`, never through the app. + +**Deploying this change:** run `npm run migrate:identity` straight after +the deploy. It flags the existing operator accounts and swaps the old +platform-wide unique indexes for per-business ones. Until it runs, +operators cannot open the Platform console and a second business cannot +reuse an email. It is safe to re-run. + ## Still open -- **Global email and phone uniqueness.** One person cannot work for two - businesses, and signup reveals whether an address is already registered. - **Isolation by convention.** Tenant filters are still written per route. The tests cover the sales, stock, transfer and access paths, not every route. diff --git a/package.json b/package.json index 717b4a0..4716ed0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "migrate:multitenant": "tsx scripts/migrate-multitenant.ts", "test": "vitest run", "typecheck": "tsc --noEmit", - "backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts" + "backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts", + "migrate:identity": "tsx scripts/migrate-per-business-identity.ts" }, "dependencies": { "axios": "^1.7.7", diff --git a/scripts/migrate-per-business-identity.ts b/scripts/migrate-per-business-identity.ts new file mode 100644 index 0000000..d3ecff7 --- /dev/null +++ b/scripts/migrate-per-business-identity.ts @@ -0,0 +1,49 @@ +import dotenv from "dotenv"; +import path from "path"; +dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); + +import mongoose from "mongoose"; +import { connectDb } from "../src/lib/server/db"; +import { User } from "../src/lib/server/models/User"; +import { platformAdminEmails } from "../src/lib/server/platform"; + +/** + * One-off, safe to re-run. Moves user identity from platform-wide to + * per-business uniqueness: + * + * 1. Marks today's platform operators. Until now an operator was anyone + * whose email was in PLATFORM_ADMIN_EMAILS; emails were unique, so + * that was one account each. From now on emails are only unique per + * business, so access also needs the platformAdmin flag on the account. + * Run this BEFORE dropping the old index, while each listed email + * still matches exactly one account. + * 2. Syncs the User indexes: drops the old unique `email_1` and + * `phoneNumber_1`, creates unique (businessId, email) and + * (businessId, phoneNumber), and a plain email lookup index. + * + * npm run migrate:identity + */ +async function main() { + await connectDb(); + + for (const email of platformAdminEmails()) { + const accounts = await User.find({ email }).select("_id").lean(); + if (accounts.length === 1) { + await User.updateOne({ _id: accounts[0]._id }, { $set: { platformAdmin: true } }); + console.log(`Platform operator: ${email}`); + } else if (accounts.length === 0) { + console.warn(`No account for ${email} - create it with npm run seed.`); + } else { + console.warn(`${email} has ${accounts.length} accounts - set platformAdmin on the right one by hand.`); + } + } + + const dropped = await User.syncIndexes(); + console.log(`User indexes synced. Dropped: ${dropped.length ? dropped.join(", ") : "none"}.`); + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error("migration failed:", err.message); + process.exit(1); +}); diff --git a/scripts/seed.ts b/scripts/seed.ts index 38b0a7b..3b677c2 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -13,6 +13,7 @@ import { connectDb } from "../src/lib/server/db"; import { User } from "../src/lib/server/models/User"; import { Product } from "../src/lib/server/models/Product"; import { Business } from "../src/lib/server/models/Business"; +import { isPlatformAdminEmail } from "../src/lib/server/platform"; /** * Run once against a fresh database: `npm run seed`. @@ -39,10 +40,23 @@ async function seed() { } let business = await Business.findOne({ name: demoBusinessName }); - const existing = await User.findOne({ email }); + // Emails are unique per business, so look in the seeded business (or, for + // a legacy single-tenant database, for its one super admin). + const existing = business + ? await User.findOne({ email, businessId: business._id }) + : await User.findOne({ email, role: "SUPER_ADMIN" }); + // The seed is the only way (besides the identity migration) to make an + // operator: the flag is honoured only while the email is also listed in + // PLATFORM_ADMIN_EMAILS. + const platformAdmin = isPlatformAdminEmail(email); if (existing) { console.log(`Super admin (${email}) already exists, skipping`); + if (platformAdmin && !existing.platformAdmin) { + existing.platformAdmin = true; + await existing.save(); + console.log(`Marked ${email} as a platform operator`); + } if (!business) { // Backfill a business for a legacy single-tenant admin if needed. business = await Business.create({ name: demoBusinessName, ownerId: existing._id }); @@ -61,6 +75,7 @@ async function seed() { phoneNumber: "254700000000", email, passwordHash, + platformAdmin, businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below }); business = await Business.create({ name: demoBusinessName, ownerId: owner._id }); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index bc57232..c2cf544 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -7,7 +7,8 @@ import { Branch } from "@/lib/server/models/Branch"; import { Business } from "@/lib/server/models/Business"; import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; -import { isPlatformAdminEmail } from "@/lib/server/platform"; +import { isPlatformAdmin } from "@/lib/server/platform"; +import { objectId } from "@/lib/server/validation"; import { getEnv } from "@/lib/server/env"; import { tenantAccessError } from "@/lib/server/subscription"; @@ -21,6 +22,9 @@ const loginSchema = z.object({ password: z.string().min(1), deviceId: z.string().uuid(), deviceLabel: z.string().max(120).optional(), + // Which business to sign in to, when the same email and password match + // accounts in more than one (the first attempt returns the choices). + businessId: objectId.optional(), }); async function enforceDeviceBinding(userId: string, deviceId: string) { @@ -44,32 +48,70 @@ export async function POST(req: NextRequest) { if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } - const { email, password, deviceId, deviceLabel } = parsed.data; + const { email, password, deviceId, deviceLabel, businessId } = parsed.data; - const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash"); - if (!user || !user.isActive) { + // An email is unique within a business, so it can belong to accounts in + // several businesses, each with its own password. + const filter: Record = { email: email.toLowerCase(), isActive: true }; + if (businessId) filter.businessId = businessId; + const candidates = await User.find(filter).select("+passwordHash"); + if (candidates.length === 0) { return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 }); } - if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { - const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000); + const now = Date.now(); + const unlocked = candidates.filter((u) => !u.lockedUntil || u.lockedUntil.getTime() <= now); + if (unlocked.length === 0) { + const soonest = Math.min(...candidates.map((u) => u.lockedUntil!.getTime())); + const minutesLeft = Math.ceil((soonest - now) / 60000); return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 }); } - const validPassword = await user.comparePassword(password); - if (!validPassword) { - user.failedLoginAttempts += 1; - if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) { - user.lockedUntil = new Date(Date.now() + LOCKOUT_MS); - user.failedLoginAttempts = 0; + const matched = []; + for (const u of unlocked) { + if (await u.comparePassword(password)) matched.push(u); + } + + if (matched.length === 0) { + // Count the failure against every account this email could have meant. + for (const u of unlocked) { + u.failedLoginAttempts += 1; + if (u.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) { + u.lockedUntil = new Date(now + LOCKOUT_MS); + u.failedLoginAttempts = 0; + } + await u.save(); } - await user.save(); return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 }); } - user.failedLoginAttempts = 0; - user.lockedUntil = null; - await user.save(); + // A correct password unlocks only the accounts it belongs to; a person + // with a different password in another business is not affected. + for (const u of matched) { + u.failedLoginAttempts = 0; + u.lockedUntil = null; + await u.save(); + } + + if (matched.length > 1) { + // Only someone who knows the password learns which businesses it opens. + const businesses = await Business.find({ _id: { $in: matched.map((u) => u.businessId) } }) + .select("name") + .lean(); + const nameById = new Map(businesses.map((b) => [b._id.toString(), b.name])); + return NextResponse.json( + { + error: "This email signs in to more than one business. Choose one.", + chooseBusiness: matched.map((u) => ({ + businessId: u.businessId.toString(), + businessName: nameById.get(u.businessId.toString()) ?? "Business", + role: u.role, + })), + }, + { status: 300 } + ); + } + const user = matched[0]; const tenantError = await tenantAccessError(user.businessId); if (tenantError) { @@ -123,7 +165,7 @@ export async function POST(req: NextRequest) { email: user.email, }, redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard", - isPlatformAdmin: isPlatformAdminEmail(user.email), + isPlatformAdmin: isPlatformAdmin(user), }); res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions()); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index ff178c1..da965d7 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -4,7 +4,7 @@ import { User } from "@/lib/server/models/User"; import { Branch } from "@/lib/server/models/Branch"; import { Business } from "@/lib/server/models/Business"; import { getAuth } from "@/lib/server/auth"; -import { isPlatformAdminEmail } from "@/lib/server/platform"; +import { isPlatformAdmin } from "@/lib/server/platform"; import { isMpesaEnabled } from "@/lib/server/env"; import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers"; @@ -38,7 +38,7 @@ export async function GET(req: NextRequest) { branchName: branch?.branchName ?? null, phoneNumber: user.phoneNumber, email: user.email, - isPlatformAdmin: isPlatformAdminEmail(user.email), + isPlatformAdmin: isPlatformAdmin(user), }, subscription: { tier, diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index ea4d4fd..db150a1 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -53,22 +53,16 @@ export async function POST(req: NextRequest) { const lowerEmail = email.toLowerCase(); - // Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS) - // and signup does not verify email ownership, so an operator address - // must never be claimable through public signup. Operator accounts are - // created with `npm run seed` instead. + // Operator addresses are reserved, so nobody can open a business that + // looks like the operator's. (Access itself also needs the platformAdmin + // flag, which signup never sets.) if (isPlatformAdminEmail(lowerEmail)) { return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 }); } - const emailTaken = await User.findOne({ email: lowerEmail }).lean(); - if (emailTaken) { - return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 }); - } - const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean(); - if (phoneTaken) { - return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 }); - } + // No platform-wide duplicate check: a new business has no users yet, and + // the same person may already work for another business. (Checking would + // also tell anyone whether an email or phone is registered elsewhere.) const passwordHash = await bcrypt.hash(password, 12); diff --git a/src/app/api/branches/route.ts b/src/app/api/branches/route.ts index 344d2b9..770b2b7 100644 --- a/src/app/api/branches/route.ts +++ b/src/app/api/branches/route.ts @@ -71,14 +71,15 @@ export async function POST(req: NextRequest) { } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 400 }); } + // Unique within this business only (see the User model). const lowerEmail = manager.email.toLowerCase(); - const emailTaken = await User.findOne({ email: lowerEmail }).lean(); + const emailTaken = await User.exists({ businessId, email: lowerEmail }); if (emailTaken) { - return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 }); + return NextResponse.json({ error: "Someone in your business already uses this email." }, { status: 409 }); } - const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean(); + const phoneTaken = await User.exists({ businessId, phoneNumber: managerPhone }); if (phoneTaken) { - return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 }); + return NextResponse.json({ error: "Someone in your business already uses this phone number." }, { status: 409 }); } } @@ -107,7 +108,7 @@ export async function POST(req: NextRequest) { } catch (err: any) { await Branch.deleteOne({ _id: branch._id }).catch(() => {}); if (err?.code === 11000) { - return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 }); + return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 }); } return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 }); } diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 1a849b3..3513a6b 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -115,7 +115,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ user: obj, inviteUrl }, { status: 201 }); } catch (err: any) { if (err?.code === 11000) { - return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 }); + return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 }); } throw err; } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 056cc3e..2fc66c2 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -12,6 +12,18 @@ interface ActiveDevice { lastUsedAt: string; } +interface BusinessChoice { + businessId: string; + businessName: string; + role: string; +} + +const ROLE_LABELS: Record = { + SUPER_ADMIN: "Owner", + BRANCH_MANAGER: "Branch manager", + DELIVERY: "Cashier", +}; + export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState(""); @@ -21,6 +33,8 @@ export default function LoginPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [deviceConflict, setDeviceConflict] = useState(null); + // Set when this email and password open accounts in more than one business. + const [choices, setChoices] = useState(null); const [invited, setInvited] = useState(false); useEffect(() => { @@ -29,6 +43,10 @@ export default function LoginPage() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + await signIn(); + } + + async function signIn(businessId?: string) { setError(null); setDeviceConflict(null); setLoading(true); @@ -41,11 +59,15 @@ export default function LoginPage() { password, deviceId: getDeviceId(), deviceLabel: getDeviceLabel(), + businessId, }), }); router.replace(result.redirectTo); + return; } catch (err) { - if (err instanceof ApiError && err.status === 409) { + if (err instanceof ApiError && err.status === 300) { + setChoices((err.body?.chooseBusiness as BusinessChoice[]) ?? []); + } else if (err instanceof ApiError && err.status === 409) { setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []); setError(err.message); } else if (err instanceof ApiError) { @@ -75,7 +97,35 @@ export default function LoginPage() { Run every branch with confidence — sales, stock, M-Pesa and cash.

- {deviceConflict ? ( + {choices && !deviceConflict ? ( +
+

Which business do you want to sign in to?

+ {error && choices.length === 0 &&

{error}

} +
    + {choices.map((c) => ( +
  • + +
  • + ))} +
+ +
+ ) : deviceConflict ? (

{error}

    diff --git a/src/lib/server/models/User.ts b/src/lib/server/models/User.ts index 4ae6cf5..f93ff91 100644 --- a/src/lib/server/models/User.ts +++ b/src/lib/server/models/User.ts @@ -10,8 +10,16 @@ export interface IUser extends Document { name: string; role: UserRole; branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner) + // Email and phone are unique WITHIN a business, not across the platform, + // so one person can work for two businesses (each account has its own + // password and role). Login asks which business when the same email and + // password match accounts in more than one. phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login email: string; // login identifier for every role, via the single /login page + // Platform operator (cross-tenant console). Only set by `npm run seed` or + // the identity migration, never through the app, and only honoured while + // the email is also listed in PLATFORM_ADMIN_EMAILS. + platformAdmin: boolean; passwordHash: string; isActive: boolean; failedLoginAttempts: number; @@ -33,8 +41,9 @@ const userSchema = new Schema( name: { type: String, required: true, trim: true }, role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" }, branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null }, - phoneNumber: { type: String, required: true, unique: true, index: true }, - email: { type: String, required: true, unique: true, lowercase: true, trim: true }, + phoneNumber: { type: String, required: true }, + email: { type: String, required: true, lowercase: true, trim: true }, + platformAdmin: { type: Boolean, default: false }, passwordHash: { type: String, required: true, select: false }, isActive: { type: Boolean, default: true }, failedLoginAttempts: { type: Number, default: 0 }, @@ -45,6 +54,13 @@ const userSchema = new Schema( { timestamps: true } ); +userSchema.index({ businessId: 1, email: 1 }, { unique: true }); +userSchema.index({ businessId: 1, phoneNumber: 1 }, { unique: true }); +// Login looks an email up across businesses. Named so it never clashes with +// the old platform-wide unique `email_1` index, which the identity +// migration (npm run migrate:identity) drops. +userSchema.index({ email: 1 }, { name: "email_lookup" }); + // Enforce role/branch invariants at the model layer — belt-and-braces // alongside API-level validation. Every role authenticates the same way // now (email + password on the single /login page), so there is no diff --git a/src/lib/server/platform.ts b/src/lib/server/platform.ts index 1fab84f..29df84c 100644 --- a/src/lib/server/platform.ts +++ b/src/lib/server/platform.ts @@ -26,21 +26,30 @@ import type { NextRequest } from "next/server"; import { getAuth } from "@/lib/server/auth"; import { User } from "@/lib/server/models/User"; +/** + * Whether this account is a platform operator. Two conditions, because + * emails are only unique within a business: any owner can create a staff + * account with any email in their own business, so an email match alone + * must never grant cross-tenant access. The account must also carry the + * platformAdmin flag, which the app never sets (seed/migration only). + * Removing an email from PLATFORM_ADMIN_EMAILS revokes access at once. + */ +export function isPlatformAdmin(user: { email?: string | null; platformAdmin?: boolean | null }): boolean { + return user.platformAdmin === true && isPlatformAdminEmail(user.email); +} + /** * Resolve and authorize the current request as a platform operator. * Returns the authenticated User's id/email on success, or null if the * caller is not a platform admin. The DB must already be connected. - * - * The JWT has no email, so we re-read the User and match its email - * against PLATFORM_ADMIN_EMAILS. */ export async function requirePlatformAdmin( req: NextRequest ): Promise<{ userId: string; email: string } | null> { const auth = getAuth(req); if (!auth) return null; - const user = await User.findById(auth.sub).select("email isActive").lean(); + const user = await User.findById(auth.sub).select("email isActive platformAdmin").lean(); if (!user || !user.isActive) return null; - if (!isPlatformAdminEmail(user.email)) return null; + if (!isPlatformAdmin(user)) return null; return { userId: auth.sub, email: user.email }; } \ No newline at end of file diff --git a/tests/identity.test.ts b/tests/identity.test.ts new file mode 100644 index 0000000..841b435 --- /dev/null +++ b/tests/identity.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import bcrypt from "bcryptjs"; +import { User } from "@/lib/server/models/User"; +import * as users from "@/app/api/users/route"; +import * as login from "@/app/api/auth/login/route"; +import * as me from "@/app/api/auth/me/route"; +import * as platformBusinesses from "@/app/api/platform/businesses/route"; +import { ACCESS_COOKIE } from "@/lib/server/auth"; +import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; + +const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" }; +const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts + +async function hire(t: Tenant, as: Actor, person = PERSON) { + return users.POST( + req("POST", "/api/users", { as, body: { ...person, role: "DELIVERY", branchId: t.branchA, password: "password-123" } }) + ); +} + +const signIn = (email: string, password: string, businessId?: string) => + login.POST(req("POST", "/api/auth/login", { body: { email, password, deviceId: uuid(), businessId } })); + +async function setPassword(email: string, businessId: string, password: string) { + await User.updateOne({ email, businessId }, { passwordHash: await bcrypt.hash(password, 4) }); +} + +function accessCookie(res: Response): string { + const cookie = res.headers.get("set-cookie") ?? ""; + return cookie.match(new RegExp(`${ACCESS_COOKIE}=([^;]+)`))![1]; +} + +describe("email and phone are unique per business", () => { + it("lets one person work for two businesses with the same email and phone", async () => { + const a = await makeTenant("Alpha"); + const b = await makeTenant("Beta"); + expect((await hire(a, a.owner)).status).toBe(201); + expect((await hire(b, b.owner)).status).toBe(201); + expect(await User.countDocuments({ email: PERSON.email })).toBe(2); + }); + + it("still refuses a duplicate inside the same business", async () => { + const a = await makeTenant("Alpha"); + await hire(a, a.owner); + const sameEmail = await hire(a, a.owner, { ...PERSON, phoneNumber: "0733000222" }); + const samePhone = await hire(a, a.owner, { ...PERSON, email: "other@aquaflow.test" }); + expect([sameEmail.status, samePhone.status]).toEqual([409, 409]); + }); +}); + +describe("login when an email exists in several businesses", () => { + it("signs straight in when only one account's password matches", async () => { + const a = await makeTenant("Alpha"); + const b = await makeTenant("Beta"); + await hire(a, a.owner); + await hire(b, b.owner); + await setPassword(PERSON.email, a.businessId, "alpha-secret"); + await setPassword(PERSON.email, b.businessId, "beta-secret"); + + const res = await json(await signIn(PERSON.email, "beta-secret")); + expect(res.status).toBe(200); + expect(res.body.user.businessId).toBe(b.businessId); + }); + + it("asks which business when the same password opens both, then signs in to the one chosen", async () => { + const a = await makeTenant("Alpha"); + const b = await makeTenant("Beta"); + await hire(a, a.owner); + await hire(b, b.owner); + await setPassword(PERSON.email, a.businessId, "same-secret"); + await setPassword(PERSON.email, b.businessId, "same-secret"); + + const ask = await json(await signIn(PERSON.email, "same-secret")); + expect(ask.status).toBe(300); + expect(ask.body.chooseBusiness.map((c: any) => c.businessName).sort()).toEqual(["Alpha", "Beta"]); + + const chosen = await json(await signIn(PERSON.email, "same-secret", a.businessId)); + expect(chosen.status).toBe(200); + expect(chosen.body.user.businessId).toBe(a.businessId); + }); + + it("reveals nothing about other businesses to a wrong password", async () => { + const a = await makeTenant("Alpha"); + const b = await makeTenant("Beta"); + await hire(a, a.owner); + await hire(b, b.owner); + const res = await json(await signIn(PERSON.email, "wrong-password")); + expect(res.status).toBe(401); + expect(res.body.chooseBusiness).toBeUndefined(); + expect(res.body.error).toBe("Incorrect email or password"); + }); + + it("does not let a wrong guess at one business unlock or reset the other", async () => { + const a = await makeTenant("Alpha"); + const b = await makeTenant("Beta"); + await hire(a, a.owner); + await hire(b, b.owner); + await setPassword(PERSON.email, a.businessId, "alpha-secret"); + await setPassword(PERSON.email, b.businessId, "beta-secret"); + for (let i = 0; i < 2; i++) await signIn(PERSON.email, "wrong"); + await signIn(PERSON.email, "alpha-secret"); // resets Alpha's counter only + const [alpha, beta] = await Promise.all([ + User.findOne({ email: PERSON.email, businessId: a.businessId }), + User.findOne({ email: PERSON.email, businessId: b.businessId }), + ]); + expect([alpha!.failedLoginAttempts, beta!.failedLoginAttempts]).toEqual([0, 2]); + }); +}); + +describe("platform operator access", () => { + it("is not granted by an email match alone: another business cannot mint an operator", async () => { + const mallory = await makeTenant("Mallory Water"); + await hire(mallory, mallory.owner, { name: "Fake operator", email: OPERATOR, phoneNumber: "0744000333" }); + await setPassword(OPERATOR, mallory.businessId, "i-am-the-operator"); + + const res = await signIn(OPERATOR, "i-am-the-operator"); + expect(res.status).toBe(200); + expect((await res.clone().json()).isPlatformAdmin).toBe(false); + + const consoleRes = await platformBusinesses.GET( + req("GET", "/api/platform/businesses", { cookies: { [ACCESS_COOKIE]: accessCookie(res) } }) + ); + expect(consoleRes.status).toBe(403); + }); + + it("is granted to the flagged account whose email is listed", async () => { + const home = await makeTenant("Operator Co"); + await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" }); + await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true }); + await setPassword(OPERATOR, home.businessId, "real-operator"); + + const res = await signIn(OPERATOR, "real-operator"); + const cookies = { [ACCESS_COOKIE]: accessCookie(res) }; + expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(200); + expect((await json(await me.GET(req("GET", "/x", { cookies })))).body.user.isPlatformAdmin).toBe(true); + }); + + it("is revoked by removing the email from PLATFORM_ADMIN_EMAILS, even with the flag", async () => { + const home = await makeTenant("Operator Co"); + await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" }); + await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true }); + await setPassword(OPERATOR, home.businessId, "real-operator"); + const cookies = { [ACCESS_COOKIE]: accessCookie(await signIn(OPERATOR, "real-operator")) }; + + const listed = process.env.PLATFORM_ADMIN_EMAILS; + process.env.PLATFORM_ADMIN_EMAILS = ""; + try { + expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(403); + } finally { + process.env.PLATFORM_ADMIN_EMAILS = listed; + } + }); +});