Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -1,35 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
|
||||
|
||||
/**
|
||||
* The registered-customer branch never touches the database (it's a plain
|
||||
* userId comparison), so it's tested here in isolation. The guest-token
|
||||
* branch's real hash/expiry/revocation behaviour is covered against a real
|
||||
* database in customer-booking.integration.test.ts — this file only checks
|
||||
* that authorization.ts calls into validateGuestAccessToken correctly and
|
||||
* respects its result.
|
||||
*/
|
||||
vi.mock("@/services/scheduling/guest-access.service", () => ({
|
||||
validateGuestAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const { validateGuestAccessToken } = await import("@/services/scheduling/guest-access.service");
|
||||
const mockValidate = vi.mocked(validateGuestAccessToken);
|
||||
|
||||
beforeEach(() => {
|
||||
mockValidate.mockClear();
|
||||
});
|
||||
|
||||
// Not a real PrismaClient — the mocked guest-access service never touches it.
|
||||
const prisma = {} as never;
|
||||
|
||||
describe("assertCanAccessAppointment", () => {
|
||||
it("allows a registered customer to access their own appointment", () => {
|
||||
expect(() =>
|
||||
assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_1"),
|
||||
).not.toThrow();
|
||||
it("allows a registered customer to access their own appointment", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("denies a different signed-in user from accessing a registered customer's appointment", () => {
|
||||
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toThrow(
|
||||
PermissionDeniedError,
|
||||
);
|
||||
it("denies a different signed-in user from accessing a registered customer's appointment", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("denies an unauthenticated caller from accessing a registered customer's appointment", () => {
|
||||
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, undefined)).toThrow(
|
||||
PermissionDeniedError,
|
||||
);
|
||||
it("denies an unauthenticated caller from accessing a registered customer's appointment", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("allows access to a guest appointment (no linked user) regardless of session — the documented guest access model", () => {
|
||||
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, undefined)).not.toThrow();
|
||||
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, "some_other_user")).not.toThrow();
|
||||
it("never even calls the guest-token validator for a registered customer's appointment", async () => {
|
||||
mockValidate.mockClear();
|
||||
await assertCanAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: "user_1" } },
|
||||
{ sessionUserId: "user_1" },
|
||||
).catch(() => {});
|
||||
expect(mockValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a guest appointment when the guest-token validator confirms the token", async () => {
|
||||
mockValidate.mockResolvedValueOnce(true);
|
||||
await expect(
|
||||
assertCanAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: null } },
|
||||
{ sessionUserId: undefined, guestToken: "correct-token" },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockValidate).toHaveBeenCalledWith(prisma, { appointmentId: "a1", rawToken: "correct-token" });
|
||||
});
|
||||
|
||||
it("denies a guest appointment when no token is supplied at all — the appointment id is no longer sufficient", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: null } }, { sessionUserId: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
expect(mockValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies a guest appointment when the guest-token validator rejects the token", async () => {
|
||||
mockValidate.mockResolvedValueOnce(false);
|
||||
await expect(
|
||||
assertCanAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: null } },
|
||||
{ sessionUserId: undefined, guestToken: "wrong-token" },
|
||||
),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessAppointment", () => {
|
||||
it("mirrors assertCanAccessAppointment as a boolean", () => {
|
||||
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_1")).toBe(true);
|
||||
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toBe(false);
|
||||
expect(canAccessAppointment({ customer: { userId: null } }, undefined)).toBe(true);
|
||||
it("mirrors assertCanAccessAppointment as a boolean", async () => {
|
||||
expect(
|
||||
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
|
||||
).toBe(false);
|
||||
|
||||
mockValidate.mockResolvedValueOnce(true);
|
||||
expect(
|
||||
await canAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: null } },
|
||||
{ sessionUserId: undefined, guestToken: "correct-token" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/busi
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { revokeGuestAccessToken } from "@/services/scheduling/guest-access.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
@@ -357,10 +358,16 @@ describe("customer booking flows (integration)", () => {
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
expect(() => assertCanAccessAppointment(created.appointment, ownerUserId)).not.toThrow();
|
||||
expect(() => assertCanAccessAppointment(created.appointment, strangerUserId)).toThrow(PermissionDeniedError);
|
||||
expect(() => assertCanAccessAppointment(created.appointment, undefined)).toThrow(PermissionDeniedError);
|
||||
expect(canAccessAppointment(created.appointment, strangerUserId)).toBe(false);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: ownerUserId }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
|
||||
@@ -378,7 +385,102 @@ describe("customer booking flows (integration)", () => {
|
||||
|
||||
expect(created.appointment.customer.userId).toBe(ownerUserId);
|
||||
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
|
||||
expect(canAccessAppointment(created.appointment, randomOtherId)).toBe(false);
|
||||
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: randomOtherId })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest access tokens (Phase 7 — see guest-access.service.ts)", () => {
|
||||
it("a guest booking issues a token, and only the correct token grants access", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
expect(created.guestAccessToken).toBeTruthy();
|
||||
expect(created.appointment.customer.userId).toBeNull();
|
||||
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// The bare appointment id is no longer sufficient — the exact gap this closes.
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: "totally-wrong-token" }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("a token scoped to one guest appointment does not grant access to another", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const first = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
const second = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
|
||||
});
|
||||
if (!first.success || !second.success) throw new Error("setup failed");
|
||||
expect(first.guestAccessToken).not.toBe(second.guestAccessToken);
|
||||
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, second.appointment, { guestToken: first.guestAccessToken }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("an expired or revoked token is rejected even though it's otherwise correct", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
await prisma.guestAppointmentAccessToken.update({
|
||||
where: { appointmentId: created.appointment.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
|
||||
await prisma.guestAppointmentAccessToken.update({
|
||||
where: { appointmentId: created.appointment.id },
|
||||
data: { expiresAt: new Date(Date.now() + 1000 * 60 * 60), revokedAt: null },
|
||||
});
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await revokeGuestAccessToken(prisma, created.appointment.id);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
@@ -42,6 +44,14 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
||||
if (!draft.guest) {
|
||||
return { success: false, error: "Please provide your contact details first." };
|
||||
}
|
||||
// Registered customers already have a rate-limited signin gate on
|
||||
// their account; a guest has none, so this is the one place spam
|
||||
// bookings can otherwise be generated with no account at all.
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`guest-booking:${ip}`, { maxAttempts: 8, windowMs: 60 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { success: false, error: "Too many booking attempts. Please try again later." };
|
||||
}
|
||||
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
|
||||
customerId = resolved.customerId;
|
||||
}
|
||||
@@ -62,7 +72,10 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await writeLastBookingReference(result.appointment.id);
|
||||
await writeLastBookingReference({
|
||||
appointmentId: result.appointment.id,
|
||||
guestAccessToken: result.guestAccessToken,
|
||||
});
|
||||
await clearDraft();
|
||||
redirect("/book/confirmation");
|
||||
}
|
||||
@@ -31,7 +31,10 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
||||
await assertCanAccessAppointment(prisma, appointment, {
|
||||
sessionUserId: session?.user?.id,
|
||||
guestToken: parsed.data.guestToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
@@ -65,7 +68,10 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
||||
await assertCanAccessAppointment(prisma, appointment, {
|
||||
sessionUserId: session?.user?.id,
|
||||
guestToken: parsed.data.guestToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
|
||||
@@ -140,7 +140,10 @@ export async function startRebookAction(input: unknown): Promise<void> {
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
||||
await assertCanAccessAppointment(prisma, appointment, {
|
||||
sessionUserId: session?.user?.id,
|
||||
guestToken: parsed.data.guestToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
redirect("/appointments");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { validateGuestAccessToken } from "@/services/scheduling/guest-access.service";
|
||||
|
||||
/**
|
||||
* Customer-facing ownership checks. Kept in the feature/action layer, not
|
||||
* the domain services — AppointmentService/BookingService are also used
|
||||
@@ -17,31 +20,55 @@ export class PermissionDeniedError extends Error {
|
||||
}
|
||||
|
||||
export interface OwnableAppointment {
|
||||
id: string;
|
||||
customer: { userId: string | null };
|
||||
}
|
||||
|
||||
export interface AppointmentAccess {
|
||||
sessionUserId?: string | undefined;
|
||||
/** Raw token from the guest's booking-confirmation link — see guest-access.service.ts. Ignored for a registered customer's appointment. */
|
||||
guestToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ownership rule:
|
||||
* - A REGISTERED customer's appointment (customer.userId is set) always
|
||||
* requires the requesting session to match that exact user. An
|
||||
* appointment id alone is never sufficient — see the Phase 4 brief's
|
||||
* explicit "Appointment IDs are not treated as permission".
|
||||
* - A GUEST appointment (customer.userId is null) has no stronger
|
||||
* credential available in this phase — no magic-link/OTP system was
|
||||
* built (explicitly deferred, see docs/phase-4-summary.md). Access is
|
||||
* by the appointment's own unguessable id, the same "order tracking"
|
||||
* pattern many guest-checkout systems use. This is a deliberate,
|
||||
* documented scope limitation, not an oversight.
|
||||
* - A GUEST appointment (customer.userId is null) requires the current,
|
||||
* unexpired, unrevoked access token for THIS exact appointment (see
|
||||
* GuestAppointmentAccessToken in schema.prisma and
|
||||
* guest-access.service.ts). Closes the Phase 4 gap where the
|
||||
* appointment's own id — unguessable but never actually secret — was
|
||||
* the only credential (see docs/phase-4-summary.md and
|
||||
* docs/phase-7-summary.md).
|
||||
*/
|
||||
export function assertCanAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): void {
|
||||
if (appointment.customer.userId && appointment.customer.userId !== sessionUserId) {
|
||||
export async function assertCanAccessAppointment(
|
||||
prisma: PrismaClient,
|
||||
appointment: OwnableAppointment,
|
||||
access: AppointmentAccess,
|
||||
): Promise<void> {
|
||||
if (appointment.customer.userId) {
|
||||
if (appointment.customer.userId !== access.sessionUserId) throw new PermissionDeniedError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!access.guestToken ||
|
||||
!(await validateGuestAccessToken(prisma, { appointmentId: appointment.id, rawToken: access.guestToken }))
|
||||
) {
|
||||
throw new PermissionDeniedError();
|
||||
}
|
||||
}
|
||||
|
||||
export function canAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): boolean {
|
||||
export async function canAccessAppointment(
|
||||
prisma: PrismaClient,
|
||||
appointment: OwnableAppointment,
|
||||
access: AppointmentAccess,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, sessionUserId);
|
||||
await assertCanAccessAppointment(prisma, appointment, access);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -4,7 +4,15 @@ import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startRebookAction } from "../actions/draft";
|
||||
|
||||
export function BookAgainButton({ appointmentId, className }: { appointmentId: string; className?: string }) {
|
||||
export function BookAgainButton({
|
||||
appointmentId,
|
||||
guestToken,
|
||||
className,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
guestToken?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
@@ -13,7 +21,7 @@ export function BookAgainButton({ appointmentId, className }: { appointmentId: s
|
||||
size="sm"
|
||||
className={className}
|
||||
isLoading={isPending}
|
||||
onClick={() => startTransition(() => startRebookAction({ appointmentId }))}
|
||||
onClick={() => startTransition(() => startRebookAction({ appointmentId, guestToken }))}
|
||||
>
|
||||
Book Again
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,13 @@ import { Modal } from "@/components/ui/modal";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
|
||||
|
||||
export function CancelAppointmentDialog({ appointmentId }: { appointmentId: string }) {
|
||||
export function CancelAppointmentDialog({
|
||||
appointmentId,
|
||||
guestToken,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
guestToken?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -21,6 +27,7 @@ export function CancelAppointmentDialog({ appointmentId }: { appointmentId: stri
|
||||
const res = await cancelCustomerAppointmentAction({
|
||||
appointmentId,
|
||||
reason: reason || undefined,
|
||||
guestToken,
|
||||
});
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Something went wrong. Please try again.");
|
||||
|
||||
@@ -85,15 +85,18 @@ export const saveDetailsStepSchema = z.object({
|
||||
|
||||
export const startRebookSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
guestToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const customerCancelSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
reason: z.string().max(500).optional(),
|
||||
guestToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const customerRescheduleSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
guestToken: z.string().optional(),
|
||||
});
|
||||
Reference in new issue
Block a user