fix: harden booking and meeting flows
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Payload, PayloadRequest } from "payload";
|
||||
import type { BookingRequest, Event, MeetingSetting } from "../payload-types";
|
||||
import { customerBookingView } from "../lib/booking/customerView";
|
||||
import { authorizeMeeting, meetingAccessVersion, meetingWindow } from "../lib/meeting/access";
|
||||
import { createMeetingToken, verifyMeetingToken } from "../lib/meeting/token";
|
||||
import { createLiveKitToken, getLiveKitRoomName } from "../lib/meeting/livekit";
|
||||
import { allowVerificationEmail } from "../lib/auth/verificationRateLimit";
|
||||
import { safeLoginRedirect } from "../lib/auth/redirect";
|
||||
import { parseBerlinDate, berlinDayBounds } from "../lib/calendar/timezone";
|
||||
import { getAvailableSlots } from "../lib/booking/slots";
|
||||
import { handleConfirmation } from "../collections/BookingRequests";
|
||||
import { Events } from "../collections/Events";
|
||||
import { AvailabilityOverrides } from "../collections/AvailabilityOverrides";
|
||||
|
||||
process.env.MEETING_SESSION_SECRET = "test-only-meeting-secret";
|
||||
process.env.LIVEKIT_API_KEY = "test-api-key";
|
||||
process.env.LIVEKIT_API_SECRET = "test-only-livekit-secret-long-enough";
|
||||
|
||||
const settings = { hostJoinWindowMinutes: 30, participantJoinWindowMinutes: 10, meetingCloseAfterMinutes: 10 } as MeetingSetting;
|
||||
const event = { id: 1, slug: "private-call", title: "Fixture", date: "2027-07-01T10:00:00.000Z", startTime: "2027-07-01T10:00:00.000Z", endTime: "2027-07-01T11:00:00.000Z", isOnline: true, _status: "published", meetingPassword: "fixture" } as Event;
|
||||
|
||||
test("customer props exclude private fields, even inside populated relationships", () => {
|
||||
const booking = { id: 1, internalNote: "SECRET_NOTE", user: { emailVerificationTokenHash: "SECRET_HASH" },
|
||||
offer: { title: "Consultation", internalNote: "SECRET_OFFER" },
|
||||
linkedEvent: { slug: "private-call", isOnline: true, meetingPassword: "SECRET_PASSWORD", bookingRequest: { internalNote: "SECRET_NESTED" } },
|
||||
proposedAlternative: { date: "2027-07-01", internalNote: "SECRET_ALTERNATIVE" },
|
||||
} as unknown as BookingRequest;
|
||||
const serialized = JSON.stringify(customerBookingView(booking));
|
||||
assert.ok(!serialized.includes("SECRET"));
|
||||
assert.ok(serialized.includes("Consultation"));
|
||||
assert.ok(serialized.includes("private-call"));
|
||||
});
|
||||
|
||||
test("closed and malformed meeting windows never grant access", () => {
|
||||
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T11:10:00Z")).allowed, false);
|
||||
assert.equal(meetingWindow({ ...event, endTime: "invalid" }, settings, "participant").allowed, false);
|
||||
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T09:49:59Z")).allowed, false);
|
||||
assert.equal(meetingWindow(event, settings, "participant", new Date("2027-07-01T10:00:00Z")).allowed, true);
|
||||
});
|
||||
|
||||
test("cancelled private meetings are rejected using the current booking status", async () => {
|
||||
let queried = false;
|
||||
const payload = { find: async (args: { where: unknown }) => {
|
||||
queried = true;
|
||||
assert.ok(JSON.stringify(args.where).includes('"confirmed"'));
|
||||
return { docs: [] };
|
||||
} } as unknown as Payload;
|
||||
assert.equal(await authorizeMeeting(payload, { ...event, isPrivateBooking: true }, "participant"), null);
|
||||
assert.equal(queried, true);
|
||||
});
|
||||
|
||||
test("password and schedule changes invalidate credentials and isolate the new room", () => {
|
||||
const version = meetingAccessVersion(event);
|
||||
const changed = meetingAccessVersion({ ...event, meetingPassword: "replacement" });
|
||||
assert.notEqual(version, changed);
|
||||
assert.notEqual(version, meetingAccessVersion({ ...event, endTime: "2027-07-01T12:00:00Z" }));
|
||||
assert.notEqual(getLiveKitRoomName(event.slug!, version), getLiveKitRoomName(event.slug!, changed));
|
||||
const issued = createMeetingToken({ eventSlug: event.slug!, name: "Guest", role: "participant", accessVersion: version });
|
||||
assert.equal(verifyMeetingToken(issued.token)?.accessVersion, version);
|
||||
const expired = createMeetingToken({ eventSlug: event.slug!, name: "Guest", role: "participant", ttlMs: 0 });
|
||||
assert.equal(verifyMeetingToken(expired.token), null);
|
||||
});
|
||||
|
||||
test("LiveKit connection credentials expire within the allowed window and one minute", async () => {
|
||||
const deadline = Date.now() + 30000;
|
||||
const jwt = await createLiveKitToken({ eventSlug: "fixture", participantId: "guest", name: "Guest", role: "participant", expiresAt: deadline });
|
||||
const claims = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
|
||||
assert.ok(claims.exp * 1000 <= deadline);
|
||||
assert.ok(claims.exp - claims.nbf <= 60);
|
||||
assert.equal(claims.video.roomAdmin, undefined);
|
||||
});
|
||||
|
||||
test("verification budgets apply across operations, address casing and changing IPs", () => {
|
||||
for (let i = 0; i < 3; i++) assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": `fixture-${i}` }) }, "Target@example.com"), true);
|
||||
assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": "fixture-new" }) }, "target@EXAMPLE.com"), false);
|
||||
for (let i = 0; i < 3; i++) assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": `account-${i}` }) }, `target${i}@example.com`, 17), true);
|
||||
assert.equal(allowVerificationEmail({ headers: new Headers({ "x-forwarded-for": "account-new" }) }, "fresh@example.com", 17), false);
|
||||
});
|
||||
|
||||
test("login rejects external, script and backslash redirects", () => {
|
||||
for (const value of ["javascript:alert(1)", "https://other.invalid", "//other.invalid", "/\\other.invalid", "/\n/other.invalid", null]) assert.equal(safeLoginRedirect(value), "/konto");
|
||||
assert.equal(safeLoginRedirect("/angebote/beratung?x=1"), "/angebote/beratung?x=1");
|
||||
});
|
||||
|
||||
test("slot date parsing uses Berlin boundaries and rejects impossible calendar dates", () => {
|
||||
assert.equal(parseBerlinDate("2027-07-01")?.toISOString(), "2027-06-30T22:00:00.000Z");
|
||||
assert.equal(parseBerlinDate("2027-02-30"), null);
|
||||
assert.equal(parseBerlinDate("2027-7-1"), null);
|
||||
assert.equal(berlinDayBounds(parseBerlinDate("2027-03-28")!).end.getTime() - parseBerlinDate("2027-03-28")!.getTime() + 1, 23 * 3600000);
|
||||
});
|
||||
|
||||
test("invalid slot durations terminate safely without querying the database", async () => {
|
||||
const payload = { find: () => { throw new Error("Unexpected query"); } } as unknown as Payload;
|
||||
for (const durationMinutes of [0, -1, NaN, Infinity, 0.5]) {
|
||||
assert.equal((await getAvailableSlots(payload, { durationMinutes, from: new Date(), to: new Date() })).size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("public users cannot read absence reasons", () => {
|
||||
const read = AvailabilityOverrides.access!.read!;
|
||||
assert.equal(read({ req: { user: null } } as Parameters<typeof read>[0]), false);
|
||||
});
|
||||
|
||||
function hookArgs(data: Record<string, unknown>, originalDoc: Record<string, unknown>, payload: Record<string, unknown>) {
|
||||
return { data, originalDoc, req: { transactionID: "fixture", payload: { ...payload,
|
||||
db: { sessions: { fixture: { db: { execute: async () => undefined } } } },
|
||||
} } as unknown as PayloadRequest } as Parameters<typeof handleConfirmation>[0];
|
||||
}
|
||||
|
||||
test("rescheduling a confirmed booking checks conflicts instead of silently overlapping", async () => {
|
||||
const original = { id: 1, status: "confirmed", appointmentType: "onsite", date: event.date, startTime: event.startTime, endTime: event.endTime };
|
||||
await assert.rejects(() => handleConfirmation(hookArgs({ startTime: "2027-07-01T10:30:00Z" }, original, {
|
||||
count: async () => ({ totalDocs: 1 }),
|
||||
})), { status: 409 });
|
||||
});
|
||||
|
||||
test("cancelling a private booking disables its event", async () => {
|
||||
let updated = false;
|
||||
await handleConfirmation(hookArgs({ status: "cancelled" }, { id: 1, status: "confirmed", appointmentType: "online", date: event.date, startTime: event.startTime, endTime: event.endTime, linkedEvent: 2 }, {
|
||||
findByID: async () => ({ ...event, id: 2, isPrivateBooking: true }),
|
||||
update: async (args: { data: { isOnline: boolean } }) => { assert.equal(args.data.isOnline, false); updated = true; },
|
||||
}));
|
||||
assert.equal(updated, true);
|
||||
});
|
||||
|
||||
test("repeated bookings for one offer get distinct private meeting slugs", async () => {
|
||||
const slugs: string[] = [];
|
||||
const payload = { count: async () => ({ totalDocs: 0 }), findByID: async () => ({ title: "Same offer" }),
|
||||
create: async (args: { data: { slug: string } }) => { slugs.push(args.data.slug); return { id: slugs.length }; },
|
||||
};
|
||||
for (let i = 0; i < 2; i++) await handleConfirmation(hookArgs({ status: "confirmed", offer: 1, appointmentType: "online", date: event.date, startTime: event.startTime, endTime: event.endTime }, {}, payload));
|
||||
assert.equal(new Set(slugs).size, 2);
|
||||
});
|
||||
|
||||
test("ordinary event edits preserve the password; explicit regeneration changes it", async () => {
|
||||
const hook = Events.hooks!.beforeChange![0];
|
||||
const unchanged = await hook({ data: { isOnline: true }, originalDoc: event } as unknown as Parameters<typeof hook>[0]);
|
||||
assert.equal(unchanged.meetingPassword, undefined);
|
||||
const changed = await hook({ data: { regeneratePassword: true }, originalDoc: event } as unknown as Parameters<typeof hook>[0]);
|
||||
assert.ok(changed.meetingPassword);
|
||||
assert.notEqual(changed.meetingPassword, event.meetingPassword);
|
||||
});
|
||||
Reference in New Issue
Block a user