Files
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ab21c7f805 refactor: Cal.diy (#28903)
* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com

This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main:

- Rebrand Cal.com to Cal.diy across the entire codebase
- Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions
- Switch license from AGPL-3.0 to MIT
- Remove docs/ directory (migrated to Nextra at cal.diy)
- Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc.
- Clean up .env.example for self-hosted Cal.diy
- Update Docker image references to calcom/cal.diy
- Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork
- Add PR welcome bot for Cal.diy contributors
- Fix API v2 breaking changes oasdiff ignore entries
- Replace Blacksmith CI runners with default GitHub Actions

3893 files changed, 20789 insertions(+), 411020 deletions(-)

Co-Authored-By: [email protected] <[email protected]>

* refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* rip out org related comments in api v2

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-04-15 09:52:36 -03:00

1293 lines
43 KiB
TypeScript

import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
import type { BookingAuditContext } from "../../dto/types";
import type { AuditActorType } from "../../repository/IAuditActorRepository";
import type {
BookingAuditAction,
BookingAuditType,
BookingAuditWithActor,
IBookingAuditRepository,
} from "../../repository/IBookingAuditRepository";
import { BookingAuditErrorCode, BookingAuditPermissionError } from "../BookingAuditAccessService";
import { BookingAuditViewerService } from "../BookingAuditViewerService";
vi.mock("@calcom/features/users/repositories/UserRepository");
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
vi.mock("@calcom/features/credentials/repositories/CredentialRepository");
vi.mock("@calcom/prisma", () => ({
default: {},
prisma: {},
}));
type MockBooking = {
userId: number;
user: {
id: number;
email: string;
};
eventType: {
teamId: number | null;
parent: { teamId: number } | null;
hosts: unknown[];
users: unknown[];
} | null;
attendees: unknown[];
fromReschedule?: string | null;
};
type MockAttendee = {
id: number;
name: string | null;
email: string;
};
type MockMembership = {
userId: number;
teamId: number;
};
const DB = {
users: {} as Record<string, MockUser>,
bookings: {} as Record<string, MockBooking>,
auditLogs: {} as Record<string, BookingAuditWithActor[]>,
attendees: {} as Record<number, MockAttendee>,
memberships: {} as Record<string, boolean>, // key: "userId-teamId"
};
const createMockTeamBooking = (
bookingUid: string,
overrides?: {
userId?: number;
teamId?: number | null;
parentTeamId?: number;
fromReschedule?: string | null;
}
) => {
const booking: MockBooking = {
userId: overrides?.userId ?? 123,
user: {
id: overrides?.userId ?? 123,
email: "[email protected]",
},
eventType: {
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
hosts: [],
users: [],
},
attendees: [],
fromReschedule: overrides?.fromReschedule,
};
DB.bookings[bookingUid] = booking;
return booking;
};
const createMockAuditLog = (
bookingUid: string,
overrides?: Partial<{
id: string;
action: BookingAuditAction;
type: BookingAuditType;
timestamp: Date;
createdAt: Date;
updatedAt: Date;
data: unknown;
actorId: string;
actorType: AuditActorType;
actorUserUuid: string | null;
actorName: string | null;
actorAttendeeId: number | null;
context: BookingAuditContext | null;
}>
): BookingAuditWithActor => {
const log: BookingAuditWithActor = {
id: overrides?.id ?? "audit-log-1",
bookingUid,
action: overrides?.action ?? "CREATED",
type: overrides?.type ?? "RECORD_CREATED",
timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"),
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
actorId: overrides?.actorId ?? "actor-1",
data: overrides?.data ?? {
version: 1,
fields: {
startTime: 1705315200000,
endTime: 1705318800000,
status: "ACCEPTED",
hostUserUuid: "host-uuid",
},
},
source: "WEBAPP" as const,
operationId: "operation-id-123",
context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null,
actor: {
id: overrides?.actorId ?? "actor-1",
type: overrides?.actorType ?? ("USER" as const),
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as
| string
| null,
attendeeId: (overrides && "actorAttendeeId" in overrides ? overrides.actorAttendeeId : null) as
| number
| null,
credentialId: null,
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
createdAt: new Date("2024-01-01T00:00:00Z"),
},
};
if (!DB.auditLogs[bookingUid]) {
DB.auditLogs[bookingUid] = [];
}
DB.auditLogs[bookingUid].push(log);
return log;
};
type MockUser = {
id: number;
uuid: string;
name: string | null;
email: string;
avatarUrl: string | null;
};
const createMockUser = (
uuid?: string,
overrides?: Partial<{
id: number;
uuid: string;
name: string | null;
email: string;
avatarUrl: string | null;
}>
) => {
const userUuid = uuid ?? overrides?.uuid ?? `user-uuid-${overrides?.id ?? 123}`;
const user: MockUser = {
id: overrides?.id ?? 123,
uuid: userUuid,
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
email: overrides?.email ?? "[email protected]",
avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null,
};
if (userUuid) {
DB.users[userUuid] = user;
}
return user;
};
const createMockAttendee = (id: number, overrides?: Partial<{ name: string | null; email: string }>) => {
const attendee: MockAttendee = {
id,
name: overrides?.name ?? null,
email: overrides?.email ?? `attendee-${id}@example.com`,
};
DB.attendees[id] = attendee;
return attendee;
};
const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => {
const key = `${userId}-${teamId}`;
DB.memberships[key] = true;
};
describe("BookingAuditViewerService - Integration Tests", () => {
let service: BookingAuditViewerService;
let mockBookingRepository: {
findByUidIncludeEventType: Mock<BookingRepository["findByUidIncludeEventType"]>;
getFromRescheduleUid: Mock<BookingRepository["getFromRescheduleUid"]>;
};
let mockUserRepository: {
getUserOrganizationAndTeams: Mock<UserRepository["getUserOrganizationAndTeams"]>;
findByUuid: Mock<UserRepository["findByUuid"]>;
findByUuids: Mock<UserRepository["findByUuids"]>;
};
let mockBookingAuditRepository: {
create: Mock<IBookingAuditRepository["create"]>;
findAllForBooking: Mock<IBookingAuditRepository["findAllForBooking"]>;
findRescheduledLogsOfBooking: Mock<IBookingAuditRepository["findRescheduledLogsOfBooking"]>;
};
let mockMembershipRepository: {
hasMembership: Mock<MembershipRepository["hasMembership"]>;
};
let mockAttendeeRepository: {
findById: Mock;
findByIds: Mock;
};
let mockCredentialRepository: {
findByCredentialId: Mock<CredentialRepository["findByCredentialId"]>;
findByIds: Mock;
};
let mockLog: {
error: Mock;
};
beforeEach(() => {
vi.clearAllMocks();
DB.users = {};
DB.bookings = {};
DB.auditLogs = {};
DB.attendees = {};
DB.memberships = {};
mockBookingRepository = {
findByUidIncludeEventType: vi.fn().mockImplementation(({ bookingUid }: { bookingUid: string }) => {
return Promise.resolve(DB.bookings[bookingUid] ?? null);
}),
getFromRescheduleUid: vi.fn().mockImplementation((bookingUid: string) => {
const booking = DB.bookings[bookingUid];
return Promise.resolve(booking?.fromReschedule ?? null);
}),
};
mockUserRepository = {
getUserOrganizationAndTeams: vi.fn(),
findByUuid: vi.fn().mockImplementation(({ uuid }: { uuid: string }) => {
return Promise.resolve(DB.users[uuid] ?? null);
}),
findByUuids: vi.fn().mockImplementation(({ uuids }: { uuids: string[] }) => {
return Promise.resolve(uuids.map((uuid) => DB.users[uuid]).filter(Boolean));
}),
};
mockBookingAuditRepository = {
create: vi.fn(),
findAllForBooking: vi.fn().mockImplementation((bookingUid: string) => {
return Promise.resolve(DB.auditLogs[bookingUid] ?? []);
}),
findRescheduledLogsOfBooking: vi.fn().mockImplementation((bookingUid: string) => {
const logs = DB.auditLogs[bookingUid] ?? [];
return Promise.resolve(logs.filter((log) => log.action === "RESCHEDULED"));
}),
};
mockMembershipRepository = {
hasMembership: vi.fn().mockImplementation(({ userId, teamId }: { userId: number; teamId: number }) => {
const key = `${userId}-${teamId}`;
return Promise.resolve(DB.memberships[key] ?? false);
}),
};
mockAttendeeRepository = {
findById: vi.fn().mockImplementation((id: number) => {
return Promise.resolve(DB.attendees[id] ?? null);
}),
findByIds: vi.fn().mockImplementation(({ ids }: { ids: number[] }) => {
return Promise.resolve(ids.map((id) => DB.attendees[id]).filter(Boolean));
}),
};
mockCredentialRepository = {
findByCredentialId: vi.fn(),
findByIds: vi.fn().mockImplementation(() => Promise.resolve([])),
};
mockLog = {
error: vi.fn(),
};
vi.mocked(BookingRepository).mockImplementation(function () {
return mockBookingRepository as unknown as BookingRepository;
});
vi.mocked(UserRepository).mockImplementation(function () {
return mockUserRepository as unknown as UserRepository;
});
vi.mocked(MembershipRepository).mockImplementation(function () {
return mockMembershipRepository as unknown as MembershipRepository;
});
vi.mocked(CredentialRepository).mockImplementation(function () {
return mockCredentialRepository as unknown as CredentialRepository;
});
service = new BookingAuditViewerService({
bookingAuditRepository: mockBookingAuditRepository as unknown as IBookingAuditRepository,
userRepository: mockUserRepository as unknown as UserRepository,
bookingRepository: mockBookingRepository as unknown as BookingRepository,
membershipRepository: mockMembershipRepository as unknown as MembershipRepository,
attendeeRepository: mockAttendeeRepository as unknown as IAttendeeRepository,
credentialRepository: mockCredentialRepository as unknown as CredentialRepository,
log: mockLog as unknown as ISimpleLogger,
});
});
describe("getAuditLogsForBooking", () => {
describe("when user has permission to view audit logs", () => {
beforeEach(() => {
createMockTeamBooking("booking-uid-123", { teamId: 100 });
});
it("should return enriched audit logs with actor information", async () => {
createMockAuditLog("booking-uid-123");
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.bookingUid).toBe("booking-uid-123");
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0]).toMatchObject({
id: "audit-log-1",
bookingUid: "booking-uid-123",
action: "CREATED",
type: "RECORD_CREATED",
actor: expect.objectContaining({
type: "USER",
displayName: "John Doe",
displayEmail: "[email protected]",
displayAvatar: null,
}),
});
});
it("should format timestamps as ISO strings", async () => {
createMockAuditLog("booking-uid-123", {
timestamp: new Date("2024-01-15T10:00:00Z"),
createdAt: new Date("2024-01-15T10:00:00Z"),
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs[0].timestamp).toBe("2024-01-15T10:00:00.000Z");
expect(result.auditLogs[0].createdAt).toBe("2024-01-15T10:00:00.000Z");
});
it("should include action display title with translation key", async () => {
createMockAuditLog("booking-uid-123", { action: "CREATED" });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs[0].actionDisplayTitle).toEqual({
key: "booking_audit_action.created",
params: {
host: "Unknown",
},
});
});
it("should handle multiple audit logs in correct order", async () => {
createMockAuditLog("booking-uid-123", {
id: "log-1",
action: "CREATED",
timestamp: new Date("2024-01-15T10:00:00Z"),
data: {
version: 1,
fields: {
startTime: 1705315200000,
endTime: 1705318800000,
status: "ACCEPTED",
hostUserUuid: null,
},
},
});
createMockAuditLog("booking-uid-123", {
id: "log-2",
action: "ACCEPTED",
timestamp: new Date("2024-01-15T11:00:00Z"),
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } },
});
createMockAuditLog("booking-uid-123", {
id: "log-3",
action: "CANCELLED",
timestamp: new Date("2024-01-15T12:00:00Z"),
data: {
version: 1,
fields: {
status: { old: "ACCEPTED", new: "CANCELLED" },
cancellationReason: "User requested",
cancelledBy: "user-123",
},
},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(3);
expect(result.auditLogs[0].id).toBe("log-1");
expect(result.auditLogs[1].id).toBe("log-2");
expect(result.auditLogs[2].id).toBe("log-3");
});
it("should return empty array when no audit logs exist", async () => {
// Don't create any audit logs in DB
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.bookingUid).toBe("booking-uid-123");
expect(result.auditLogs).toEqual([]);
});
});
describe("when enriching actor information", () => {
beforeEach(() => {
createMockTeamBooking("booking-uid-123", { teamId: 100 });
});
it("should enrich USER actor with user details from repository", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "USER",
actorUserUuid: "user-uuid-456",
});
createMockUser("user-uuid-456", {
name: "Jane Smith",
email: "[email protected]",
avatarUrl: "https://example.com/avatar.jpg",
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({
uuids: expect.arrayContaining(["user-uuid-456"]),
});
expect(result.auditLogs[0].actor).toMatchObject({
displayName: "Jane Smith",
displayEmail: "[email protected]",
displayAvatar: "https://example.com/avatar.jpg",
});
});
it("should use email as display name when user name is null", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "USER",
actorName: null,
});
createMockUser("user-uuid-123", { name: null, email: "[email protected]" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor.displayName).toBe("[email protected]");
});
it("should show 'Deleted User' when user not found in repository", async () => {
createMockAuditLog("booking-uid-123", { actorType: "USER" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor).toMatchObject({
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
});
});
it("should show 'Cal.diy' for SYSTEM actor", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "SYSTEM",
actorUserUuid: null,
data: {
version: 1,
fields: {
startTime: 1705315200000,
endTime: 1705318800000,
status: "ACCEPTED",
hostUserUuid: null,
},
},
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor).toMatchObject({
type: "SYSTEM",
displayName: "Cal.diy",
displayEmail: null,
displayAvatar: null,
});
expect(mockUserRepository.findByUuid).not.toHaveBeenCalled();
});
it("should show 'Guest' for GUEST actor without name", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "GUEST",
actorUserUuid: null,
actorName: null,
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor).toMatchObject({
type: "GUEST",
displayName: "Guest",
displayEmail: null,
displayAvatar: null,
});
});
it("should use provided name for GUEST actor", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "GUEST",
actorUserUuid: null,
actorName: "External Guest",
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].actor.displayName).toBe("External Guest");
});
it("should show 'Attendee' for ATTENDEE actor without name", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: null,
actorAttendeeId: 999,
});
createMockAttendee(999, { name: null, email: "[email protected]" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [999] });
expect(result.auditLogs[0].actor).toMatchObject({
type: "ATTENDEE",
displayName: "[email protected]",
displayEmail: "[email protected]",
displayAvatar: null,
});
});
it("should use provided name for ATTENDEE actor", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: "Meeting Participant",
actorAttendeeId: 888,
});
createMockAttendee(888, { name: "Meeting Participant", email: "[email protected]" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [888] });
expect(result.auditLogs[0].actor.displayName).toBe("Meeting Participant");
expect(result.auditLogs[0].actor.displayEmail).toBe("[email protected]");
});
it("should show 'Deleted Attendee' when attendee not found in repository", async () => {
createMockAuditLog("booking-uid-123", {
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: null,
actorAttendeeId: 777,
});
// Don't create attendee in DB - should return null
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [777] });
expect(result.auditLogs[0].actor).toMatchObject({
type: "ATTENDEE",
displayName: "Deleted Attendee",
displayEmail: null,
displayAvatar: null,
});
});
});
describe("when handling rescheduled bookings", () => {
beforeEach(() => {
createMockTeamBooking("new-booking-uid", { teamId: 100 });
});
it("should include rescheduled from log when booking was created from reschedule", async () => {
createMockAuditLog("new-booking-uid", {
id: "current-log",
action: "CREATED",
});
createMockAuditLog("old-booking-uid", {
id: "rescheduled-log",
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: 1705315200000, new: 1705401600000 },
endTime: { old: 1705318800000, new: 1705405200000 },
rescheduledToUid: { old: null, new: "new-booking-uid" },
},
},
});
createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "new-booking-uid",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(2);
expect(result.auditLogs[1].id).toBe("rescheduled-log");
expect(result.auditLogs[1].bookingUid).toBe("new-booking-uid");
expect(result.auditLogs[1].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from");
expect(result.auditLogs[1].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid");
expect(result.auditLogs[0].id).toBe("current-log");
});
it("should not include rescheduled from log when booking was not rescheduled", async () => {
createMockAuditLog("booking-uid-123");
createMockTeamBooking("booking-uid-123", { teamId: 100 });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(mockBookingAuditRepository.findRescheduledLogsOfBooking).not.toHaveBeenCalled();
});
it("should continue without rescheduled from log when matching log not found", async () => {
createMockAuditLog("new-booking-uid");
createMockAuditLog("old-booking-uid", {
id: "rescheduled-log",
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: 1705315200000, new: 1705401600000 },
endTime: { old: 1705318800000, new: 1705405200000 },
rescheduledToUid: { old: null, new: "different-booking-uid" },
},
},
});
createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "new-booking-uid",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].id).toBe("audit-log-1");
expect(mockLog.error).toHaveBeenCalledWith(
"No rescheduled log found for booking old-booking-uid -> new-booking-uid"
);
});
});
describe("when permission check fails", () => {
it("should throw error when user is not the booking owner", async () => {
createMockTeamBooking("booking-uid-123", { teamId: 100, userId: 456 });
await expect(
service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
})
).rejects.toThrow(BookingAuditPermissionError);
expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled();
});
it("should throw error when booking not found", async () => {
// Don't create booking in DB - should return null
await expect(
service.getAuditLogsForBooking({
bookingUid: "non-existent-booking",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
})
).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
expect(mockBookingAuditRepository.findAllForBooking).not.toHaveBeenCalled();
});
});
describe("when handling different timezones", () => {
beforeEach(() => {
createMockTeamBooking("booking-uid-123", { teamId: 100 });
});
it("should pass user timezone to action service for display formatting", async () => {
createMockAuditLog("booking-uid-123", {
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: 1705315200000, new: 1705401600000 },
endTime: { old: 1705318800000, new: 1705405200000 },
rescheduledToUid: { old: null, new: "new-booking-uid" },
},
},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/Los_Angeles",
organizationId: 200,
});
expect(result.auditLogs[0].actionDisplayTitle).toHaveProperty("params");
expect(result.auditLogs[0].displayJson).toBeDefined();
});
});
describe("when handling seat audit actions", () => {
beforeEach(() => {
createMockTeamBooking("booking-uid-123", { teamId: 100 });
});
it("should handle SEAT_BOOKED action with seat information", async () => {
createMockAuditLog("booking-uid-123", {
id: "seat-booked-log",
action: "SEAT_BOOKED",
data: {
version: 1,
fields: {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "[email protected]",
attendeeName: "Jane Attendee",
startTime: 1705315200000,
endTime: 1705318800000,
},
},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].action).toBe("SEAT_BOOKED");
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.seat_booked");
expect(result.auditLogs[0].displayJson).toBeDefined();
});
it("should handle SEAT_RESCHEDULED action with time changes", async () => {
createMockAuditLog("booking-uid-123", {
id: "seat-rescheduled-log",
action: "SEAT_RESCHEDULED",
data: {
version: 1,
fields: {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "[email protected]",
startTime: {
old: new Date("2024-01-15T10:00:00Z").getTime(),
new: new Date("2024-01-16T10:00:00Z").getTime(),
},
endTime: {
old: new Date("2024-01-15T11:00:00Z").getTime(),
new: new Date("2024-01-16T11:00:00Z").getTime(),
},
rescheduledToBookingUid: {
old: null,
new: "new-booking-uid",
},
},
},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "America/New_York",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].action).toBe("SEAT_RESCHEDULED");
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.seat_rescheduled");
expect(result.auditLogs[0].actionDisplayTitle.params).toBeDefined();
expect(result.auditLogs[0].actionDisplayTitle.components).toBeDefined();
expect(result.auditLogs[0].displayJson).toBeDefined();
});
it("should handle SEAT_RESCHEDULED action without moving to different booking", async () => {
createMockAuditLog("booking-uid-123", {
id: "seat-rescheduled-same-booking",
action: "SEAT_RESCHEDULED",
data: {
version: 1,
fields: {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "[email protected]",
startTime: {
old: new Date("2024-01-15T10:00:00Z").getTime(),
new: new Date("2024-01-15T11:00:00Z").getTime(),
},
endTime: {
old: new Date("2024-01-15T11:00:00Z").getTime(),
new: new Date("2024-01-15T12:00:00Z").getTime(),
},
rescheduledToBookingUid: {
old: null,
new: null,
},
},
},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].action).toBe("SEAT_RESCHEDULED");
expect(result.auditLogs[0].actionDisplayTitle.components).toBeUndefined();
});
});
describe("when handling impersonatedBy context", () => {
beforeEach(() => {
createMockTeamBooking("booking-uid-123", { teamId: 100 });
});
it("should enrich impersonatedBy when user exists", async () => {
createMockAuditLog("booking-uid-123", {
context: { impersonatedBy: "impersonator-uuid-456" },
});
createMockUser("user-uuid-123");
createMockUser("impersonator-uuid-456", {
id: 456,
name: "Admin User",
email: "[email protected]",
avatarUrl: "https://example.com/admin-avatar.jpg",
});
// Host lookup should return null - don't create host-uuid in DB
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({
uuids: expect.arrayContaining(["impersonator-uuid-456"]),
});
expect(result.auditLogs[0].impersonatedBy).toMatchObject({
displayName: "Admin User",
displayEmail: "[email protected]",
displayAvatar: "https://example.com/admin-avatar.jpg",
});
});
it("should use email as displayName when impersonator name is null", async () => {
createMockAuditLog("booking-uid-123", {
context: { impersonatedBy: "impersonator-uuid-456" },
});
createMockUser("user-uuid-123");
createMockUser("impersonator-uuid-456", {
id: 456,
name: null,
email: "[email protected]",
avatarUrl: null,
});
// Host lookup should return null - don't create host-uuid in DB
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].impersonatedBy).toMatchObject({
displayName: "[email protected]",
displayEmail: "[email protected]",
displayAvatar: null,
});
});
it("should show 'Deleted User' when impersonator user not found", async () => {
createMockAuditLog("booking-uid-123", {
context: { impersonatedBy: "impersonator-uuid-456" },
});
createMockUser("user-uuid-123");
// Don't create impersonator-uuid-456 in DB - should return null
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({
uuids: expect.arrayContaining(["impersonator-uuid-456"]),
});
expect(result.auditLogs[0].impersonatedBy).toMatchObject({
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
});
});
it("should return null when context is null", async () => {
createMockAuditLog("booking-uid-123", {
context: null,
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].impersonatedBy).toBeNull();
});
it("should return null when impersonatedBy is not in context", async () => {
createMockAuditLog("booking-uid-123", {
context: {},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].impersonatedBy).toBeNull();
});
});
describe("when enrichment fails for a single audit log", () => {
beforeEach(() => {
createMockTeamBooking("booking-uid-123", { teamId: 100 });
});
it("should return fallback log with hasError when enrichActorInformation throws for USER actor without userUuid", async () => {
createMockAuditLog("booking-uid-123", {
id: "failing-log",
actorType: "USER",
actorUserUuid: null,
actorName: "Test Actor",
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].hasError).toBe(true);
expect(result.auditLogs[0].actionDisplayTitle).toEqual({
key: "booking_audit_action.error_processing",
params: { actionType: "CREATED" },
});
expect(result.auditLogs[0].displayJson).toBeNull();
expect(result.auditLogs[0].displayFields).toBeNull();
expect(result.auditLogs[0].actor.displayName).toBe("Test Actor");
expect(mockLog.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to enrich audit log failing-log")
);
});
it("should return fallback log with hasError when enrichActorInformation throws for ATTENDEE actor without attendeeId", async () => {
createMockAuditLog("booking-uid-123", {
id: "failing-attendee-log",
actorType: "ATTENDEE",
actorUserUuid: null,
actorAttendeeId: null,
actorName: null,
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(1);
expect(result.auditLogs[0].hasError).toBe(true);
expect(result.auditLogs[0].actionDisplayTitle).toEqual({
key: "booking_audit_action.error_processing",
params: { actionType: "CREATED" },
});
expect(result.auditLogs[0].actor.displayName).toBe("Unknown");
expect(mockLog.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to enrich audit log failing-attendee-log")
);
});
it("should still return other logs when one log fails to enrich", async () => {
createMockAuditLog("booking-uid-123", {
id: "successful-log",
action: "CREATED",
actorType: "USER",
actorUserUuid: "user-uuid-123",
});
createMockAuditLog("booking-uid-123", {
id: "failing-log",
action: "ACCEPTED",
actorType: "USER",
actorUserUuid: null,
actorName: "Failing Actor",
});
createMockAuditLog("booking-uid-123", {
id: "another-successful-log",
action: "CANCELLED",
actorType: "SYSTEM",
actorUserUuid: null,
data: {
version: 1,
fields: {
status: { old: "ACCEPTED", new: "CANCELLED" },
cancellationReason: "Test",
cancelledBy: "user-123",
},
},
});
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs).toHaveLength(3);
expect(result.auditLogs[0].id).toBe("successful-log");
expect(result.auditLogs[0].hasError).toBeUndefined();
expect(result.auditLogs[1].id).toBe("failing-log");
expect(result.auditLogs[1].hasError).toBe(true);
expect(result.auditLogs[1].actor.displayName).toBe("Failing Actor");
expect(result.auditLogs[2].id).toBe("another-successful-log");
expect(result.auditLogs[2].hasError).toBeUndefined();
expect(result.auditLogs[2].actor.displayName).toBe("Cal.diy");
});
it("should log error message when enrichment fails", async () => {
createMockAuditLog("booking-uid-123", {
id: "error-log-id",
actorType: "USER",
actorUserUuid: null,
});
await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(mockLog.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to enrich audit log error-log-id: .+/)
);
});
it("should preserve basic log information in fallback", async () => {
const timestamp = new Date("2024-01-15T10:00:00Z");
const createdAt = new Date("2024-01-15T09:00:00Z");
createMockAuditLog("booking-uid-123", {
id: "fallback-test-log",
action: "CREATED",
type: "RECORD_CREATED",
timestamp,
createdAt,
actorType: "USER",
actorUserUuid: null,
actorName: "Test Name",
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0]).toMatchObject({
id: "fallback-test-log",
bookingUid: "booking-uid-123",
action: "CREATED",
type: "RECORD_CREATED",
timestamp: timestamp.toISOString(),
createdAt: createdAt.toISOString(),
source: "WEBAPP",
hasError: true,
actor: expect.objectContaining({
type: "USER",
displayName: "Test Name",
}),
});
});
it("should use 'Unknown' as displayName when actor name is null in fallback", async () => {
createMockAuditLog("booking-uid-123", {
id: "null-name-log",
actorType: "USER",
actorUserUuid: null,
actorName: null,
});
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
userEmail: "[email protected]",
userTimeZone: "UTC",
organizationId: 200,
});
expect(result.auditLogs[0].hasError).toBe(true);
expect(result.auditLogs[0].actor.displayName).toBe("Unknown");
});
});
});
});