This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
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);
});
});
+14 -1
View File
@@ -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;
+4 -1
View File
@@ -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");
+37 -10
View File
@@ -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.");
+3
View File
@@ -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(),
});