Skip to content

Commit

Permalink
Merge branch 'main' into feat/allow_rescheduling_past_bookings_setting
Browse files Browse the repository at this point in the history
  • Loading branch information
PeerRich authored Jan 21, 2025
2 parents fe9f702 + 0f7ecb3 commit 7954abf
Show file tree
Hide file tree
Showing 199 changed files with 2,967 additions and 613 deletions.
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ E2E_TEST_MAILHOG_ENABLED=

# Cloudflare Turnstile
NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
CLOUDFLARE_TURNSTILE_SECRET=

# Set the following value to true if you wish to enable Team Impersonation
Expand Down Expand Up @@ -367,9 +368,7 @@ APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
APP_ROUTER_AUTH_NEW_ENABLED=0
APP_ROUTER_AUTH_SAML_ENABLED=0
APP_ROUTER_AUTH_ERROR_ENABLED=0
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
APP_ROUTER_AUTH_OAUTH2_ENABLED=0

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/index.cjs b/index.cjs
index c83f700ae9998cd87b4c2d66ecbb2ad3d7b4603c..76a2200b57f0b9243e2c61464d578b67746ad5a4 100644
index c83f700..da6fc7e 100644
--- a/index.cjs
+++ b/index.cjs
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
Expand All @@ -12,4 +12,4 @@ index c83f700ae9998cd87b4c2d66ecbb2ad3d7b4603c..76a2200b57f0b9243e2c61464d578b67
+// exports['default'] = min.parsePhoneNumberFromString

// `parsePhoneNumberFromString()` named export is now considered legacy:
// it has been promoted to a default export due to being too verbose.
// it has been promoted to a default export due to being too verbose.
8 changes: 8 additions & 0 deletions .yarn/versions/727b22e1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
7 changes: 7 additions & 0 deletions .yarn/versions/c2e72c83.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
149 changes: 145 additions & 4 deletions apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { RatelimitResponse } from "@unkey/ratelimit";
import type { Request, Response } from "express";
import type { NextApiResponse, NextApiRequest } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect, vi } from "vitest";

import { handleAutoLock } from "@calcom/lib/autoLock";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";

import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";

Expand All @@ -14,6 +17,10 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
checkRateLimitAndThrowError: vi.fn(),
}));

vi.mock("@calcom/lib/autoLock", () => ({
handleAutoLock: vi.fn(),
}));

describe("rateLimitApiKey middleware", () => {
it("should return 401 if no apiKey is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
Expand Down Expand Up @@ -55,17 +62,20 @@ describe("rateLimitApiKey middleware", () => {
query: { apiKey: "test-key" },
});

const rateLimiterResponse = {
const rateLimiterResponse: RatelimitResponse = {
limit: 100,
remaining: 99,
reset: Date.now(),
success: true,
};

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
onRateLimiterResponse(rateLimiterResponse);
});
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);
Expand All @@ -89,4 +99,135 @@ describe("rateLimitApiKey middleware", () => {
expect(res._getStatusCode()).toBe(429);
expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
});

it("should lock API key when rate limit is repeatedly exceeded", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

const rateLimiterResponse: RatelimitResponse = {
success: false,
remaining: 0,
limit: 100,
reset: Date.now(),
};

// Mock rate limiter to trigger the onRateLimiterResponse callback
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// Mock handleAutoLock to indicate the key was locked
vi.mocked(handleAutoLock).mockResolvedValueOnce(true);

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);

expect(handleAutoLock).toHaveBeenCalledWith({
identifier: "test-key",
identifierType: "apiKey",
rateLimitResponse: rateLimiterResponse,
});

expect(res._getStatusCode()).toBe(429);
expect(res._getJSONData()).toEqual({ message: "Too many requests" });
});

it("should handle API key not found error during auto-lock", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

const rateLimiterResponse: RatelimitResponse = {
success: false,
remaining: 0,
limit: 100,
reset: Date.now(),
};

// Mock rate limiter to trigger the onRateLimiterResponse callback
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// Mock handleAutoLock to throw a "No user found" error
vi.mocked(handleAutoLock).mockRejectedValueOnce(new Error("No user found for this API key."));

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);

expect(handleAutoLock).toHaveBeenCalledWith({
identifier: "test-key",
identifierType: "apiKey",
rateLimitResponse: rateLimiterResponse,
});

expect(res._getStatusCode()).toBe(401);
expect(res._getJSONData()).toEqual({ message: "No user found for this API key." });
});

it("should continue if auto-lock returns false (not locked)", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

const rateLimiterResponse: RatelimitResponse = {
success: false,
remaining: 0,
limit: 100,
reset: Date.now(),
};

// Mock rate limiter to trigger the onRateLimiterResponse callback
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// Mock handleAutoLock to indicate the key was not locked
vi.mocked(handleAutoLock).mockResolvedValueOnce(false);

const next = vi.fn();
// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, next);

expect(handleAutoLock).toHaveBeenCalledWith({
identifier: "test-key",
identifierType: "apiKey",
rateLimitResponse: rateLimiterResponse,
});

// Verify headers were set but request continued
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
expect(next).toHaveBeenCalled();
});

it("should handle HttpError during rate limiting", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

// Mock checkRateLimitAndThrowError to throw HttpError
vi.mocked(checkRateLimitAndThrowError).mockRejectedValueOnce(
new HttpError({
statusCode: 429,
message: "Custom rate limit error",
})
);

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);

expect(res._getStatusCode()).toBe(429);
expect(res._getJSONData()).toEqual({ message: "Custom rate limit error" });
});
});
26 changes: 24 additions & 2 deletions apps/api/v1/lib/helpers/rateLimitApiKey.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { NextMiddleware } from "next-api-middleware";

import { handleAutoLock } from "@calcom/lib/autoLock";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";

export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
Expand All @@ -10,14 +12,34 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
await checkRateLimitAndThrowError({
identifier: req.query.apiKey as string,
rateLimitingType: "api",
onRateLimiterResponse: (response) => {
onRateLimiterResponse: async (response) => {
res.setHeader("X-RateLimit-Limit", response.limit);
res.setHeader("X-RateLimit-Remaining", response.remaining);
res.setHeader("X-RateLimit-Reset", response.reset);

try {
const didLock = await handleAutoLock({
identifier: req.query.apiKey as string, // Casting as this is verified in another middleware
identifierType: "apiKey",
rateLimitResponse: response,
});

if (didLock) {
return res.status(429).json({ message: "Too many requests" });
}
} catch (error) {
if (error instanceof Error && error.message === "No user found for this API key.") {
return res.status(401).json({ message: error.message });
}
throw error;
}
},
});
} catch (error) {
res.status(429).json({ message: "Rate limit exceeded" });
if (error instanceof HttpError) {
return res.status(error.statusCode).json({ message: error.message });
}
return res.status(429).json({ message: "Rate limit exceeded" });
}

await next();
Expand Down
8 changes: 5 additions & 3 deletions apps/api/v1/lib/validations/shared/jsonSchema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { z } from "zod";

// Helper schema for JSON fields
type Literal = boolean | number | string;
type Literal = boolean | number | string | null;
type Json = Literal | { [key: string]: Json } | Json[];
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const jsonObjectSchema = z.record(z.lazy(() => jsonSchema));
const jsonArraySchema = z.array(z.lazy(() => jsonSchema));
export const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
z.union([literalSchema, jsonObjectSchema, jsonArraySchema])
);
20 changes: 18 additions & 2 deletions apps/api/v1/pages/api/teams/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";

import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getDubCustomer } from "@calcom/features/auth/lib/dub";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
Expand Down Expand Up @@ -192,11 +193,26 @@ const generateTeamCheckoutSession = async ({
pendingPaymentTeamId: number;
ownerId: number;
}) => {
const customer = await getStripeCustomerIdFromUserId(ownerId);
const [customer, dubCustomer] = await Promise.all([
getStripeCustomerIdFromUserId(ownerId),
getDubCustomer(ownerId.toString()),
]);

const session = await stripe.checkout.sessions.create({
customer,
mode: "subscription",
allow_promotion_codes: true,
...(dubCustomer?.discount?.couponId
? {
discounts: [
{
coupon:
process.env.NODE_ENV !== "production" && dubCustomer.discount.couponTestId
? dubCustomer.discount.couponTestId
: dubCustomer.discount.couponId,
},
],
}
: { allow_promotion_codes: true }),
success_url: `${WEBAPP_URL}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
line_items: [
Expand Down
2 changes: 1 addition & 1 deletion apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/[email protected].85",
"@calcom/platform-libraries": "npm:@calcom/[email protected].86",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/[email protected]",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ describe("OAuth Client Users Endpoints", () => {
await userConnectedToOAuth(responseBody.data.user.email);
await userHasDefaultEventTypes(responseBody.data.user.id);
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
await userHasOnlyOneSchedule(responseBody.data.user.id);
});

async function userConnectedToOAuth(userEmail: string) {
Expand Down Expand Up @@ -260,6 +261,11 @@ describe("OAuth Client Users Endpoints", () => {
expect(schedule?.userId).toEqual(userId);
}

async function userHasOnlyOneSchedule(userId: number) {
const schedules = await schedulesRepositoryFixture.getByUserId(userId);
expect(schedules?.length).toEqual(1);
}

it(`should fail /POST using already used managed user email`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export class SchedulesRepositoryFixture {
async deleteAvailabilities(scheduleId: Schedule["id"]) {
return this.prismaWriteClient.availability.deleteMany({ where: { scheduleId } });
}

async getByUserId(userId: Schedule["userId"]) {
return this.prismaReadClient.schedule.findMany({ where: { userId } });
}
}
2 changes: 0 additions & 2 deletions apps/web/abTest/middlewareFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ const ROUTES: [URLPattern, boolean][] = [
["/auth/forgot-password/:path*", process.env.APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED === "1"] as const,
["/auth/login", process.env.APP_ROUTER_AUTH_LOGIN_ENABLED === "1"] as const,
["/auth/logout", process.env.APP_ROUTER_AUTH_LOGOUT_ENABLED === "1"] as const,
["/auth/new", process.env.APP_ROUTER_AUTH_NEW_ENABLED === "1"] as const,
["/auth/saml-idp", process.env.APP_ROUTER_AUTH_SAML_ENABLED === "1"] as const,
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
Expand Down
Loading

0 comments on commit 7954abf

Please sign in to comment.