diff --git a/packages/backend/src/auth/services/google.auth.service.test.ts b/packages/backend/src/auth/services/google.auth.service.test.ts new file mode 100644 index 000000000..6ac655539 --- /dev/null +++ b/packages/backend/src/auth/services/google.auth.service.test.ts @@ -0,0 +1,73 @@ +import { GaxiosError } from "gaxios"; +import { ObjectId } from "mongodb"; +import { faker } from "@faker-js/faker"; +import { Schema_User } from "@core/types/user.types"; +import { getGcalClient } from "@backend/auth/services/google.auth.service"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +jest.mock("@backend/user/queries/user.queries", () => ({ + findCompassUserBy: jest.fn(), +})); + +const mockFindCompassUserBy = findCompassUserBy as jest.MockedFunction< + typeof findCompassUserBy +>; + +describe("getGcalClient", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("throws UserError.MissingGoogleRefreshToken when user exists but has no google", async () => { + const userId = new ObjectId().toString(); + const userWithoutGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + // google is undefined - user signed up with email/password + }; + + mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); + + await expect(getGcalClient(userId)).rejects.toMatchObject({ + description: UserError.MissingGoogleRefreshToken.description, + }); + + expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); + }); + + it("throws UserError.MissingGoogleRefreshToken when user has google but no gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithEmptyGoogle = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "", // empty token - invalid + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithEmptyGoogle); + + await expect(getGcalClient(userId)).rejects.toMatchObject({ + description: UserError.MissingGoogleRefreshToken.description, + }); + }); + + it("throws GaxiosError when user is not found", async () => { + const userId = new ObjectId().toString(); + mockFindCompassUserBy.mockResolvedValue(null); + + await expect(getGcalClient(userId)).rejects.toThrow(GaxiosError); + expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); + }); +}); diff --git a/packages/backend/src/auth/services/google.auth.service.ts b/packages/backend/src/auth/services/google.auth.service.ts index c1bc23d19..b46762c44 100644 --- a/packages/backend/src/auth/services/google.auth.service.ts +++ b/packages/backend/src/auth/services/google.auth.service.ts @@ -29,18 +29,30 @@ export const getGAuthClientForUser = async ( } if (!gRefreshToken) { - const userId = "_id" in user ? (user._id as string) : undefined; + const userId = + "_id" in user + ? typeof user._id === "string" + ? user._id + : user._id.toString() + : undefined; if (!userId) { logger.error(`Expected to either get a user or a userId.`); - throw error(UserError.InvalidValue, "Auth client not initialized"); + throw error(UserError.InvalidValue, "User id is required"); } const _user = await findCompassUserBy("_id", userId); if (!_user) { logger.error(`Couldn't find user with this id: ${userId}`); - throw error(UserError.UserNotFound, "Auth client not initialized"); + throw error(UserError.UserNotFound, "User not found"); + } + + if (!_user?.google?.gRefreshToken) { + throw error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); } gRefreshToken = _user.google.gRefreshToken; @@ -61,7 +73,7 @@ export const getGcalClient = async (userId: string): Promise => { // throw gaxios error here to trigger specific session invalidation // see error.express.handler.ts - const error = new GaxiosError( + const gaxiosErr = new GaxiosError( "invalid_grant", { headers: new Headers(), @@ -101,8 +113,15 @@ export const getGcalClient = async (userId: string): Promise => { }, }, ); - error.code = "400"; - throw error; + gaxiosErr.code = "400"; + throw gaxiosErr; + } + + if (!user.google?.gRefreshToken) { + throw error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); } const gAuthClient = await getGAuthClientForUser(user); diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index f6476db8d..1ab3fa39d 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -5,7 +5,10 @@ import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { IS_DEV } from "@backend/common/constants/env.constants"; -import { errorHandler } from "@backend/common/errors/handlers/error.handler"; +import { + errorHandler, + toClientErrorPayload, +} from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; import { getEmailFromUrl, @@ -76,7 +79,7 @@ export const handleExpressError = async ( errorHandler.log(e); if (e instanceof BaseError) { - res.status(e.statusCode).send(e); + res.status(e.statusCode).json(toClientErrorPayload(e)); } else { const userId = await parseUserId(res, e); if (!userId) { diff --git a/packages/backend/src/common/errors/handlers/error.handler.test.ts b/packages/backend/src/common/errors/handlers/error.handler.test.ts new file mode 100644 index 000000000..74bf83d83 --- /dev/null +++ b/packages/backend/src/common/errors/handlers/error.handler.test.ts @@ -0,0 +1,41 @@ +import { BaseError } from "@core/errors/errors.base"; +import { Status } from "@core/errors/status.codes"; +import { + error, + toClientErrorPayload, +} from "@backend/common/errors/handlers/error.handler"; +import { UserError } from "@backend/common/errors/user/user.errors"; + +describe("error.handler", () => { + describe("toClientErrorPayload", () => { + it("returns only result and message from BaseError", () => { + const baseError = error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); + + const payload = toClientErrorPayload(baseError); + + expect(payload).toEqual({ + result: "User has not connected Google Calendar", + message: UserError.MissingGoogleRefreshToken.description, + }); + }); + + it("excludes stack, statusCode, and isOperational", () => { + const baseError = new BaseError( + "some-result", + "some-description", + Status.BAD_REQUEST, + true, + ); + + const payload = toClientErrorPayload(baseError); + + expect(payload).not.toHaveProperty("stack"); + expect(payload).not.toHaveProperty("statusCode"); + expect(payload).not.toHaveProperty("isOperational"); + expect(Object.keys(payload)).toEqual(["result", "message"]); + }); + }); +}); diff --git a/packages/backend/src/common/errors/handlers/error.handler.ts b/packages/backend/src/common/errors/handlers/error.handler.ts index 243514083..442abe454 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.ts @@ -33,6 +33,15 @@ export const genericError = ( return error(cause, result); }; +/** + * Returns a safe payload for BaseError to send to clients. + * Avoids exposing stack, isOperational, or other internal details. + */ +export const toClientErrorPayload = (e: BaseError) => ({ + result: e.result, + message: e.description, +}); + class ErrorHandler { public isOperational(error: Error): boolean { if (error instanceof BaseError) { diff --git a/packages/backend/src/common/errors/user/user.errors.ts b/packages/backend/src/common/errors/user/user.errors.ts index 91ba04703..c0870ecbf 100644 --- a/packages/backend/src/common/errors/user/user.errors.ts +++ b/packages/backend/src/common/errors/user/user.errors.ts @@ -3,7 +3,7 @@ import { ErrorMetadata } from "@backend/common/types/error.types"; interface UserErrors { InvalidValue: ErrorMetadata; - MissingGoogleUserField: ErrorMetadata; + MissingGoogleRefreshToken: ErrorMetadata; MissingUserIdField: ErrorMetadata; UserNotFound: ErrorMetadata; } @@ -14,9 +14,9 @@ export const UserError: UserErrors = { status: Status.BAD_REQUEST, isOperational: true, }, - MissingGoogleUserField: { - description: "Email field is missing from the Google user object", - status: Status.NOT_FOUND, + MissingGoogleRefreshToken: { + description: "User is missing a Google refresh token", + status: Status.BAD_REQUEST, isOperational: true, }, MissingUserIdField: { diff --git a/packages/backend/src/common/guards/google.guard.test.ts b/packages/backend/src/common/guards/google.guard.test.ts new file mode 100644 index 000000000..aa51c7d6e --- /dev/null +++ b/packages/backend/src/common/guards/google.guard.test.ts @@ -0,0 +1,164 @@ +import { ObjectId } from "mongodb"; +import { faker } from "@faker-js/faker"; +import { IDSchema } from "@core/types/type.utils"; +import { Schema_User } from "@core/types/user.types"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { requireGoogleConnection } from "@backend/common/guards/google.guard"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +const isGoogleConnected = async (userId: string): Promise => { + if (!IDSchema.safeParse(userId).success) { + return false; + } + const user = await findCompassUserBy("_id", userId); + return !!user?.google?.gRefreshToken; +}; + +jest.mock("@backend/user/queries/user.queries", () => ({ + findCompassUserBy: jest.fn(), +})); + +const mockFindCompassUserBy = findCompassUserBy as jest.MockedFunction< + typeof findCompassUserBy +>; + +describe("google.guard", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("isGoogleConnected", () => { + it("returns true when user has google.gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "valid-refresh-token", + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithGoogle); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(true); + expect(mockFindCompassUserBy).toHaveBeenCalledWith("_id", userId); + }); + + it("returns false when user has no google", async () => { + const userId = new ObjectId().toString(); + const userWithoutGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + }; + + mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(false); + }); + + it("returns false when user has google but empty gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithEmptyGoogle = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "", + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithEmptyGoogle); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(false); + }); + + it("returns false when user is not found", async () => { + const userId = new ObjectId().toString(); + mockFindCompassUserBy.mockResolvedValue(null); + + const result = await isGoogleConnected(userId); + + expect(result).toBe(false); + }); + }); + + describe("requireGoogleConnection", () => { + it("does not throw when user has google.gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + google: { + googleId: faker.string.uuid(), + picture: faker.image.url(), + gRefreshToken: "valid-refresh-token", + }, + }; + + mockFindCompassUserBy.mockResolvedValue(userWithGoogle); + + await expect(requireGoogleConnection(userId)).resolves.not.toThrow(); + }); + + it("throws when userId is not a valid ObjectId", async () => { + await expect( + requireGoogleConnection("not-an-object-id"), + ).rejects.toMatchObject({ + description: UserError.InvalidValue.description, + }); + expect(mockFindCompassUserBy).not.toHaveBeenCalled(); + }); + + it("throws UserError.UserNotFound when user does not exist", async () => { + const userId = new ObjectId().toString(); + mockFindCompassUserBy.mockResolvedValue(null); + + await expect(requireGoogleConnection(userId)).rejects.toMatchObject({ + description: UserError.UserNotFound.description, + }); + }); + + it("throws when user has no google.gRefreshToken", async () => { + const userId = new ObjectId().toString(); + const userWithoutGoogle: Schema_User & { _id: ObjectId } = { + _id: new ObjectId(userId), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + name: faker.person.fullName(), + locale: "en", + }; + + mockFindCompassUserBy.mockResolvedValue(userWithoutGoogle); + + await expect(requireGoogleConnection(userId)).rejects.toMatchObject({ + description: UserError.MissingGoogleRefreshToken.description, + }); + }); + }); +}); diff --git a/packages/backend/src/common/guards/google.guard.ts b/packages/backend/src/common/guards/google.guard.ts new file mode 100644 index 000000000..cb7d91fbb --- /dev/null +++ b/packages/backend/src/common/guards/google.guard.ts @@ -0,0 +1,22 @@ +import { IDSchema } from "@core/types/type.utils"; +import { error } from "@backend/common/errors/handlers/error.handler"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +export const requireGoogleConnection = async ( + userId: string, +): Promise => { + if (!IDSchema.safeParse(userId).success) { + throw error(UserError.InvalidValue, "Invalid user id"); + } + const user = await findCompassUserBy("_id", userId); + if (!user) { + throw error(UserError.UserNotFound, "User not found"); + } + if (!user.google?.gRefreshToken) { + throw error( + UserError.MissingGoogleRefreshToken, + "User has not connected Google Calendar", + ); + } +}; diff --git a/packages/backend/src/common/middleware/google.required.middleware.test.ts b/packages/backend/src/common/middleware/google.required.middleware.test.ts new file mode 100644 index 000000000..42b490b4f --- /dev/null +++ b/packages/backend/src/common/middleware/google.required.middleware.test.ts @@ -0,0 +1,151 @@ +import { Request, Response } from "express"; +import { ObjectId } from "mongodb"; +import { BaseError } from "@core/errors/errors.base"; +import { Status } from "@core/errors/status.codes"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { requireGoogleConnection } from "@backend/common/guards/google.guard"; +import { + requireGoogleConnectionFrom, + requireGoogleConnectionSession, +} from "@backend/common/middleware/google.required.middleware"; + +jest.mock("@backend/common/guards/google.guard", () => ({ + requireGoogleConnection: jest.fn(), +})); + +const mockRequireGoogleConnection = + requireGoogleConnection as jest.MockedFunction< + typeof requireGoogleConnection + >; + +describe("google.required.middleware", () => { + let mockReq: Partial string } }>; + let mockRes: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockNext = jest.fn(); + mockRes = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + }); + + describe("requireGoogleConnectionSession", () => { + it("calls next when user has Google connected", async () => { + const userId = new ObjectId().toString(); + mockReq = { + session: { getUserId: () => userId }, + }; + mockRequireGoogleConnection.mockResolvedValue(undefined); + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRequireGoogleConnection).toHaveBeenCalledWith(userId); + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it("responds with 400 when userId is missing", async () => { + mockReq = { session: undefined }; + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRequireGoogleConnection).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith( + UserError.MissingUserIdField.status, + ); + expect(mockRes.json).toHaveBeenCalledWith(UserError.MissingUserIdField); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("responds with BaseError statusCode when requireGoogleConnection throws", async () => { + const userId = new ObjectId().toString(); + mockReq = { + session: { getUserId: () => userId }, + }; + const baseError = new BaseError( + "User has not connected Google Calendar", + UserError.MissingGoogleRefreshToken.description, + Status.BAD_REQUEST, + true, + ); + mockRequireGoogleConnection.mockRejectedValue(baseError); + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); + expect(mockRes.json).toHaveBeenCalledWith({ + result: baseError.result, + message: baseError.description, + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("calls next with error when non-BaseError is thrown", async () => { + const userId = new ObjectId().toString(); + mockReq = { + session: { getUserId: () => userId }, + }; + const unexpectedError = new Error("Database connection failed"); + mockRequireGoogleConnection.mockRejectedValue(unexpectedError); + + await requireGoogleConnectionSession( + mockReq as Parameters[0], + mockRes as Response, + mockNext, + ); + + expect(mockRes.status).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(unexpectedError); + expect(mockNext).toHaveBeenCalledTimes(1); + }); + }); + + describe("requireGoogleConnectionFrom", () => { + it("calls next when user has Google connected", async () => { + const userId = new ObjectId().toString(); + mockReq = { + params: { userId }, + }; + mockRequireGoogleConnection.mockResolvedValue(undefined); + + const middleware = requireGoogleConnectionFrom("userId"); + await middleware(mockReq as Request, mockRes as Response, mockNext); + + expect(mockRequireGoogleConnection).toHaveBeenCalledWith(userId); + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it("responds with 400 when param userId is missing", async () => { + mockReq = { + params: {}, + }; + + const middleware = requireGoogleConnectionFrom("userId"); + await middleware(mockReq as Request, mockRes as Response, mockNext); + + expect(mockRequireGoogleConnection).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith( + UserError.MissingUserIdField.status, + ); + expect(mockRes.json).toHaveBeenCalledWith(UserError.MissingUserIdField); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/common/middleware/google.required.middleware.ts b/packages/backend/src/common/middleware/google.required.middleware.ts new file mode 100644 index 000000000..02b1eece5 --- /dev/null +++ b/packages/backend/src/common/middleware/google.required.middleware.ts @@ -0,0 +1,64 @@ +import { Request, Response } from "express"; +import { NextFunction } from "express"; +import { SessionRequest } from "supertokens-node/framework/express"; +import { BaseError } from "@core/errors/errors.base"; +import { + errorHandler, + toClientErrorPayload, +} from "@backend/common/errors/handlers/error.handler"; +import { UserError } from "@backend/common/errors/user/user.errors"; +import { requireGoogleConnection } from "@backend/common/guards/google.guard"; + +export const requireGoogleConnectionSession = async ( + req: SessionRequest, + res: Response, + next: NextFunction, +) => { + const userId = req.session?.getUserId(); + + if (!userId) { + res + .status(UserError.MissingUserIdField.status) + .json(UserError.MissingUserIdField); + return; + } + + try { + await requireGoogleConnection(userId); + } catch (e) { + if (e instanceof BaseError) { + errorHandler.log(e); + res.status(e.statusCode).json(toClientErrorPayload(e)); + return; + } + return next(e); + } + + next(); +}; + +export const requireGoogleConnectionFrom = + (paramKey = "userId") => + async (req: Request, res: Response, next: NextFunction) => { + const userId = req.params[paramKey]; + + if (!userId) { + res + .status(UserError.MissingUserIdField.status) + .json(UserError.MissingUserIdField); + return; + } + + try { + await requireGoogleConnection(userId); + } catch (e) { + if (e instanceof BaseError) { + errorHandler.log(e); + res.status(e.statusCode).json(toClientErrorPayload(e)); + return; + } + return next(e); + } + + next(); + }; diff --git a/packages/backend/src/event/classes/compass.event.parser.ts b/packages/backend/src/event/classes/compass.event.parser.ts index 21c5efa4d..2502f9a41 100644 --- a/packages/backend/src/event/classes/compass.event.parser.ts +++ b/packages/backend/src/event/classes/compass.event.parser.ts @@ -191,9 +191,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _createGcal(userId, cEvent); + await _createGcal(userId, cEvent); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -222,9 +222,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _updateGcal(userId, cEvent as Schema_Event_Core); + await _updateGcal(userId, cEvent as Schema_Event_Core); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -316,9 +316,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _updateGcal(userId, cEvent as Schema_Event_Core); + await _updateGcal(userId, cEvent as Schema_Event_Core); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -347,7 +347,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(userId, cEvent.gEventId!); + const ok = cEvent.gEventId + ? await _deleteGcal(userId, cEvent.gEventId) + : true; return ok ? [operationSummary] : []; } @@ -379,7 +381,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(user, this.#event.gEventId!); + const ok = this.#event.gEventId + ? await _deleteGcal(user, this.#event.gEventId) + : true; return ok ? [operationSummary] : []; } @@ -418,7 +422,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(user, this.#event.gEventId!); + const ok = this.#event.gEventId + ? await _deleteGcal(user, this.#event.gEventId) + : true; return ok ? [operationSummary] : []; } @@ -455,9 +461,9 @@ export class CompassEventParser { case CalendarProvider.GOOGLE: { Object.assign(cEvent, { recurrence: null }); - const event = await _updateGcal(userId, cEvent); + await _updateGcal(userId, cEvent); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -490,9 +496,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const event = await _updateGcal(userId, cEvent); + await _updateGcal(userId, cEvent); - return event ? [operationSummary] : []; + return [operationSummary]; } default: return []; @@ -516,7 +522,9 @@ export class CompassEventParser { switch (calendarProvider) { case CalendarProvider.GOOGLE: { - const ok = await _deleteGcal(userId, this.#event.gEventId!); + const ok = this.#event.gEventId + ? await _deleteGcal(userId, this.#event.gEventId) + : true; return ok ? [operationSummary] : []; } diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts index ceb8738c8..7d8dddf4b 100644 --- a/packages/backend/src/event/controllers/event.controller.ts +++ b/packages/backend/src/event/controllers/event.controller.ts @@ -17,7 +17,7 @@ import { Res_Promise, SReqBody } from "@backend/common/types/express.types"; import eventService from "@backend/event/services/event.service"; import { CompassSyncProcessor } from "@backend/sync/services/sync/compass.sync.processor"; -const logger = Logger("app.event.controllers.event.controller"); +const logger = Logger("app:event.controller"); class EventController { private async processEvents(_events: CompassEvent[]) { diff --git a/packages/backend/src/event/event.routes.config.ts b/packages/backend/src/event/event.routes.config.ts index 391d08539..6e6a35725 100644 --- a/packages/backend/src/event/event.routes.config.ts +++ b/packages/backend/src/event/event.routes.config.ts @@ -2,6 +2,7 @@ import express from "express"; import { verifySession } from "supertokens-node/recipe/session/framework/express"; import authMiddleware from "@backend/auth/middleware/auth.middleware"; import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import { requireGoogleConnectionSession } from "@backend/common/middleware/google.required.middleware"; import eventController from "./controllers/event.controller"; export class EventRoutes extends CommonRoutesConfig { @@ -14,8 +15,7 @@ export class EventRoutes extends CommonRoutesConfig { .route(`/api/event`) .all(verifySession()) .get(eventController.readAll) - //@ts-ignore - .post(eventController.create); + .post(requireGoogleConnectionSession, eventController.create); this.app .route(`/api/event/deleteMany`) @@ -36,8 +36,8 @@ export class EventRoutes extends CommonRoutesConfig { .route(`/api/event/:id`) .all(verifySession()) .get(eventController.readById) - .put(eventController.update) - .delete(eventController.delete); + .put(requireGoogleConnectionSession, eventController.update) + .delete(requireGoogleConnectionSession, eventController.delete); return this.app; } diff --git a/packages/backend/src/sync/sync.routes.config.ts b/packages/backend/src/sync/sync.routes.config.ts index e8ddee4ab..f22451f07 100644 --- a/packages/backend/src/sync/sync.routes.config.ts +++ b/packages/backend/src/sync/sync.routes.config.ts @@ -6,6 +6,10 @@ import { } from "@core/constants/core.constants"; import authMiddleware from "@backend/auth/middleware/auth.middleware"; import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import { + requireGoogleConnectionFrom, + requireGoogleConnectionSession, +} from "@backend/common/middleware/google.required.middleware"; import { SyncController } from "@backend/sync/controllers/sync.controller"; import syncDebugController from "@backend/sync/controllers/sync.debug.controller"; @@ -31,7 +35,11 @@ export class SyncRoutes extends CommonRoutesConfig { this.app .route(`/api/sync/import-gcal`) - .post(verifySession(), SyncController.importGCal); + .post( + verifySession(), + requireGoogleConnectionSession, + SyncController.importGCal, + ); /*************** * DEBUG ROUTES @@ -44,6 +52,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/import-incremental/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.importIncremental, ]); @@ -51,6 +60,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/maintain/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.maintainByUser, ]); @@ -58,6 +68,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/refresh/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.refreshEventWatch, ]); @@ -65,6 +76,8 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/start`) .post([ authMiddleware.verifyIsFromCompass, + verifySession(), + requireGoogleConnectionSession, syncDebugController.startEventWatch, ]); @@ -72,6 +85,8 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/stop`) .post([ authMiddleware.verifyIsFromCompass, + verifySession(), + requireGoogleConnectionSession, syncDebugController.stopWatching, ]); @@ -79,6 +94,7 @@ export class SyncRoutes extends CommonRoutesConfig { .route(`${SYNC_DEBUG}/stop-all/:userId`) .post([ authMiddleware.verifyIsFromCompass, + requireGoogleConnectionFrom("userId"), syncDebugController.stopAllChannelWatches, ]); diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index 7582b88c2..b4e5e95bb 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -50,12 +50,13 @@ describe("UserService", () => { const user = await UserDriver.createUser(); const userId = user._id; + expect(user.google).toBeDefined(); const profile = await userService.getProfile(userId); expect(profile).toEqual( expect.objectContaining({ userId: userId.toString(), - picture: user.google.picture, + picture: user.google!.picture, firstName: user.firstName, lastName: user.lastName, name: user.name, diff --git a/packages/core/src/mappers/map.user.test.ts b/packages/core/src/mappers/map.user.test.ts index e3cd41949..9b1c93903 100644 --- a/packages/core/src/mappers/map.user.test.ts +++ b/packages/core/src/mappers/map.user.test.ts @@ -19,7 +19,7 @@ describe("Map to Compass", () => { expect(cUser.name).toEqual("Mystery Person"); expect(cUser.firstName).toEqual("Mystery"); expect(cUser.lastName).toEqual("Person"); - expect(cUser.google.picture).toEqual("not provided"); + expect(cUser.google?.picture).toEqual("not provided"); }); it("throws error if missing email", () => { expect(() => { diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 4925e528e..d034d0123 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -7,7 +7,7 @@ export interface Schema_User { lastName: string; name: string; locale: string; - google: { + google?: { googleId: string; picture: string; gRefreshToken: string;