Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b58c5f9
feat(auth): implement email/password authentication and Google accoun…
tyler-dane Feb 27, 2026
58a2a9a
fix(user.types): make google property optional in Schema_User interface
tyler-dane Feb 27, 2026
f14b836
test(auth): add unit tests for Google Calendar authentication flow
tyler-dane Feb 27, 2026
838a77d
refactor(event): streamline Google Calendar event handling
tyler-dane Feb 27, 2026
33c2e7c
refactor(auth): update Google authentication error handling
tyler-dane Feb 27, 2026
cb74f8f
feat(google): implement Google connection guards and middleware
tyler-dane Feb 27, 2026
c0ba1ce
refactor(auth): improve error messages and validation for Google conn…
tyler-dane Feb 27, 2026
fd1e5bf
refactor(auth): update error handling for missing Google refresh tokens
tyler-dane Feb 27, 2026
d75f61c
chore: remove pw plan docs from tracking (moved to docs/)
tyler-dane Feb 27, 2026
24558d4
refactor(middleware): update error response handling in Google middle…
tyler-dane Feb 27, 2026
74faedf
refactor(middleware): enhance error response structure in Google midd…
tyler-dane Feb 27, 2026
1981281
refactor(tests): standardize Google connection error handling and tes…
tyler-dane Feb 27, 2026
21ece42
refactor(errors): enhance error handling and introduce client-safe pa…
tyler-dane Feb 27, 2026
fba13b1
refactor(middleware): improve error handling in Google middleware
tyler-dane Feb 27, 2026
0610ee9
refactor(auth): enhance user ID handling and Google connection valida…
tyler-dane Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/backend/src/auth/services/google.auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 25 additions & 6 deletions packages/backend/src/auth/services/google.auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -61,7 +73,7 @@ export const getGcalClient = async (userId: string): Promise<gCalendar> => {

// 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(),
Expand Down Expand Up @@ -101,8 +113,15 @@ export const getGcalClient = async (userId: string): Promise<gCalendar> => {
},
},
);
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions packages/backend/src/common/errors/handlers/error.handler.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
});
9 changes: 9 additions & 0 deletions packages/backend/src/common/errors/handlers/error.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions packages/backend/src/common/errors/user/user.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ErrorMetadata } from "@backend/common/types/error.types";

interface UserErrors {
InvalidValue: ErrorMetadata;
MissingGoogleUserField: ErrorMetadata;
MissingGoogleRefreshToken: ErrorMetadata;
MissingUserIdField: ErrorMetadata;
UserNotFound: ErrorMetadata;
}
Expand All @@ -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: {
Expand Down
Loading