Skip to content

Commit a2bee76

Browse files
feat: Add spam blocker DI structure (calcom#24040)
* --init * -- * replace old structure with new DI * fix type * -- * moving stuff around * moving stuff around again * minor clean up * -- * resolve conflict * clea up * old schema clean up * further clean up * removing unwanted merged responsibilities * -- * improve DI and SOLID * some type fixes * --1 * clean up --cont * fix DI in facade for test * more fix * fix * checking * o.o * fix import * fix failing test --2 * fix failing test --3 * fix failing test --4 * normalization use * introduce facade container injection * uniform async telemetry spans * further improvements * replace prismock with repo mocks * ensure we don't pass prisma outside of repo * fix import * remove try catch from repo calls * async await fixes * using deps pattern * more clean up and fixes * more clean up * address feedback --1 * separation of concern * clean up * test clean up * feedback --2 * remove extra fetch * remove await * migrate _post test from prismock * fix type * -- * fix tokens path * rename AuditRepo * test --1 * update _post to integration test * fix test * fix test * test fix maybe? * -- * feedback * feedback * fixes * more feedback * NIT * use sentry and logger imports as planned * assertion in test * add missing test case * add tests for controllers and services * NITs * fix domain normalisation
1 parent 52ecea9 commit a2bee76

60 files changed

Lines changed: 3602 additions & 464 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/v1/lib/helpers/verifyApiKey.test.ts

Lines changed: 89 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,42 @@
1-
import prismock from "../../../../../tests/libs/__mocks__/prisma";
2-
1+
/**
2+
* Unit Tests for verifyApiKey middleware
3+
*
4+
* These tests verify the middleware logic without touching the database.
5+
* All dependencies (repositories, utilities) are mocked.
6+
*/
37
import type { Request, Response } from "express";
48
import type { NextApiRequest, NextApiResponse } from "next";
59
import { createMocks } from "node-mocks-http";
610
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
711

812
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
9-
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
10-
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
13+
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
14+
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
1115
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
12-
import prisma from "@calcom/prisma";
13-
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
16+
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
17+
import { UserPermissionRole } from "@calcom/prisma/enums";
1418

19+
import { isAdminGuard } from "../utils/isAdmin";
20+
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
21+
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
1522
import { verifyApiKey } from "./verifyApiKey";
1623

24+
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
25+
ApiKeyService: vi.fn(),
26+
}));
27+
28+
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
29+
PrismaApiKeyRepository: vi.fn(),
30+
}));
31+
32+
vi.mock("../utils/isAdmin", () => ({
33+
isAdminGuard: vi.fn(),
34+
}));
35+
36+
vi.mock("../utils/isLockedOrBlocked", () => ({
37+
isLockedOrBlocked: vi.fn(),
38+
}));
39+
1740
vi.mock("@calcom/lib/crypto", () => ({
1841
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
1942
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
@@ -32,17 +55,29 @@ afterEach(() => {
3255
});
3356

3457
const mockDeploymentRepository: IDeploymentRepository = {
35-
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
58+
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"),
3659
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
3760
};
3861

39-
describe("Verify API key", () => {
62+
describe("Verify API key - Unit Tests", () => {
4063
let service: ILicenseKeyService;
64+
let mockApiKeyService: ApiKeyService;
4165

4266
beforeEach(async () => {
4367
service = await LicenseKeyService.create(mockDeploymentRepository);
44-
4568
vi.spyOn(service, "checkLicense");
69+
70+
vi.spyOn(LicenseKeySingleton, "getInstance").mockResolvedValue(service as LicenseKeyService);
71+
72+
mockApiKeyService = {
73+
verifyKeyByHashedKey: vi.fn(),
74+
} as unknown as ApiKeyService;
75+
76+
vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService);
77+
vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository));
78+
79+
vi.mocked(isAdminGuard).mockReset();
80+
vi.mocked(isLockedOrBlocked).mockReset();
4681
});
4782

4883
it("should throw an error if the api key is not valid", async () => {
@@ -98,22 +133,25 @@ describe("Verify API key", () => {
98133
query: {
99134
apiKey: "cal_test_key",
100135
},
101-
prisma,
102136
});
103-
const hashedKey = hashAPIKey("test_key");
104-
await prismock.apiKey.create({
105-
data: {
106-
hashedKey,
107-
user: {
108-
create: {
109-
email: "admin@example.com",
110-
role: UserPermissionRole.ADMIN,
111-
locked: false,
112-
},
113-
},
137+
138+
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
139+
valid: true,
140+
userId: 1,
141+
user: {
142+
role: UserPermissionRole.ADMIN,
143+
locked: false,
144+
email: "admin@example.com",
114145
},
115146
});
116147

148+
vi.mocked(isAdminGuard).mockResolvedValue({
149+
isAdmin: true,
150+
scope: ScopeOfAdmin.SystemWide,
151+
});
152+
153+
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
154+
117155
const middleware = {
118156
fn: verifyApiKey,
119157
};
@@ -139,40 +177,25 @@ describe("Verify API key", () => {
139177
query: {
140178
apiKey: "cal_test_key",
141179
},
142-
prisma,
143180
});
144-
const hashedKey = hashAPIKey("test_key");
145-
await prismock.apiKey.create({
146-
data: {
147-
hashedKey,
148-
user: {
149-
create: {
150-
email: "org-admin@acme.com",
151-
role: UserPermissionRole.USER,
152-
locked: false,
153-
teams: {
154-
create: {
155-
accepted: true,
156-
role: MembershipRole.OWNER,
157-
team: {
158-
create: {
159-
name: "ACME",
160-
isOrganization: true,
161-
organizationSettings: {
162-
create: {
163-
isAdminAPIEnabled: true,
164-
orgAutoAcceptEmail: "acme.com",
165-
},
166-
},
167-
},
168-
},
169-
},
170-
},
171-
},
172-
},
181+
182+
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
183+
valid: true,
184+
userId: 2,
185+
user: {
186+
role: UserPermissionRole.USER,
187+
locked: false,
188+
email: "org-admin@acme.com",
173189
},
174190
});
175191

192+
vi.mocked(isAdminGuard).mockResolvedValue({
193+
isAdmin: true,
194+
scope: ScopeOfAdmin.OrgOwnerOrAdmin,
195+
});
196+
197+
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
198+
176199
const middleware = {
177200
fn: verifyApiKey,
178201
};
@@ -198,22 +221,25 @@ describe("Verify API key", () => {
198221
query: {
199222
apiKey: "cal_test_key",
200223
},
201-
prisma,
202224
});
203-
const hashedKey = hashAPIKey("test_key");
204-
await prismock.apiKey.create({
205-
data: {
206-
hashedKey,
207-
user: {
208-
create: {
209-
email: "locked@example.com",
210-
role: UserPermissionRole.USER,
211-
locked: true,
212-
},
213-
},
225+
226+
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
227+
valid: true,
228+
userId: 3,
229+
user: {
230+
role: UserPermissionRole.USER,
231+
locked: true,
232+
email: "locked@example.com",
214233
},
215234
});
216235

236+
vi.mocked(isAdminGuard).mockResolvedValue({
237+
isAdmin: false,
238+
scope: ScopeOfAdmin.SystemWide,
239+
});
240+
241+
vi.mocked(isLockedOrBlocked).mockResolvedValue(true);
242+
217243
const middleware = {
218244
fn: verifyApiKey,
219245
};

apps/api/v1/lib/helpers/verifyApiKey.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,15 @@ import type { NextMiddleware } from "next-api-middleware";
33
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
44
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
55
import { IS_PRODUCTION } from "@calcom/lib/constants";
6+
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
67
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
7-
import prisma from "@calcom/prisma";
8+
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
9+
import { prisma } from "@calcom/prisma";
810

911
import { isAdminGuard } from "../utils/isAdmin";
1012
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
1113
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
1214

13-
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
14-
export const dateNotInPast = function (date: Date) {
15-
const now = new Date();
16-
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
17-
return true;
18-
}
19-
};
20-
2115
// This verifies the apiKey and sets the user if it is valid.
2216
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
2317
const deploymentRepo = new DeploymentRepository(prisma);
@@ -32,24 +26,19 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
3226

3327
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
3428
const hashedKey = hashAPIKey(strippedApiKey);
35-
const apiKey = await prisma.apiKey.findUnique({
36-
where: { hashedKey },
37-
include: {
38-
user: {
39-
select: { role: true, locked: true, email: true },
40-
},
41-
},
42-
});
43-
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
44-
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
45-
return res.status(401).json({ error: "This API key is expired." });
29+
30+
// Use service layer for API key verification
31+
const apiKeyRepo = new PrismaApiKeyRepository(prisma);
32+
const apiKeyService = new ApiKeyService({ apiKeyRepo });
33+
const result = await apiKeyService.verifyKeyByHashedKey(hashedKey);
34+
35+
if (!result.valid) {
36+
return res.status(401).json({ error: result.error });
4637
}
47-
if (!apiKey.userId || !apiKey.user)
48-
return res.status(404).json({ error: "No user found for this API key." });
4938

5039
// save the user id in the request for later use
51-
req.userId = apiKey.userId;
52-
req.user = apiKey.user;
40+
req.userId = result.userId!;
41+
req.user = result.user!;
5342

5443
const { isAdmin, scope } = await isAdminGuard(req);
5544
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import type { NextApiRequest } from "next";
22

3+
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
34
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
45

56
export async function isLockedOrBlocked(req: NextApiRequest) {
67
const user = req.user;
78
if (!user?.email) return false;
8-
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
9+
return (
10+
user.locked ||
11+
(await checkIfEmailIsBlockedInWatchlistController({
12+
email: user.email,
13+
organizationId: null,
14+
span: sentrySpan,
15+
}))
16+
);
917
}

0 commit comments

Comments
 (0)