Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
da23a45
refactor(issue-templates): simplify feature request and bug report te…
tyler-dane Feb 27, 2026
c595d6b
test(socket): enhance SocketProvider tests with async act calls
tyler-dane Feb 27, 2026
c23bdf7
test(socket): add race condition handling test for useGcalSync
tyler-dane Feb 27, 2026
c8c6415
delete(svg): remove unused circle.svg file
tyler-dane Feb 27, 2026
958fb6e
fix(socket): address PR review comments for useGcalSync
tyler-dane Feb 27, 2026
f0894e7
refactor(sync): rename awaitingImportResults to isImportPending
tyler-dane Feb 27, 2026
776cc9e
refactor(socket): simplify reconnect logic and update test case
tyler-dane Feb 27, 2026
30cd79d
refactor(tests): update useGcalSync tests to remove fake timers
tyler-dane Feb 27, 2026
7270fc7
test(socket): add integration tests for Google Calendar re-authentica…
tyler-dane Feb 27, 2026
b1a6c40
refactor(sync): rename setAwaitingImportResults to setIsImportPending
tyler-dane Feb 27, 2026
a197200
feat(errors): enhance Google token error handling and add correspondi…
tyler-dane Feb 28, 2026
9fad698
feat(user): implement pruneGoogleData method and corresponding tests
tyler-dane Feb 28, 2026
5893bb0
feat(sync): implement Google revoked access handling and notifications
tyler-dane Feb 28, 2026
caa4be8
feat(api): implement Google revoked access handling in CompassApi
tyler-dane Feb 28, 2026
ae71ea2
Merge branch 'main' into fix/1478-import-bug
tyler-dane Feb 28, 2026
61e4803
chore: fix conflicts in useGcalSync
tyler-dane Feb 28, 2026
37c2e7a
refactor: revert try/catch in event.controller
tyler-dane Feb 28, 2026
ca7c910
test(compass): enhance Google revoked access handling tests
tyler-dane Feb 28, 2026
546a81b
test(socket): enhance useGcalSync tests with timer management
tyler-dane Feb 28, 2026
43f53ca
refactor(errors): improve Google error handling type safety
tyler-dane Feb 28, 2026
4437b75
feat(sync): implement deleteWatchesByUser and enhance Google error ha…
tyler-dane Feb 28, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { GaxiosError } from "gaxios";

export const createGoogleError = (
overrides: {
code?: string | number;
responseStatus?: number;
message?: string;
} = {},
) => {
const url = new URL("https://www.googleapis.com/calendar/v3");
const headers = new Headers();

const error = new GaxiosError(
overrides.message ?? "test google error",
{
headers,
url,
},
overrides.responseStatus
? {
config: {
headers,
url,
},
status: overrides.responseStatus,
statusText: "ERROR",
headers,
data: {},
ok: false,
redirected: false,
type: "error" as ResponseType,
url: url.toString(),
body: null,
bodyUsed: false,
clone: () => {
throw new Error("Not implemented");
},
arrayBuffer: async () => {
throw new Error("Not implemented");
},
blob: async () => {
throw new Error("Not implemented");
},
formData: async () => {
throw new Error("Not implemented");
},
json: async () => ({}),
text: async () => "",
bytes: async () => {
throw new Error("Not implemented");
},
}
: undefined,
);

error.code = overrides.code;

return error;
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request } from "express";
import { GaxiosError } from "gaxios";
import { SessionRequest } from "supertokens-node/framework/express";
import { GOOGLE_REVOKED } from "@core/constants/websocket.constants";
import { BaseError } from "@core/errors/errors.base";
import { Status } from "@core/errors/status.codes";
import { Logger } from "@core/logger/winston.logger";
Expand All @@ -19,6 +20,7 @@ import {
} from "@backend/common/services/gcal/gcal.utils";
import { CompassError, Info_Error } from "@backend/common/types/error.types";
import { SessionResponse } from "@backend/common/types/express.types";
import { webSocketServer } from "@backend/servers/websocket/websocket.server";
import { getSyncByToken } from "@backend/sync/util/sync.queries";
import { findCompassUserBy } from "@backend/user/queries/user.queries";
import userService from "@backend/user/services/user.service";
Expand Down Expand Up @@ -91,7 +93,7 @@ export const handleExpressError = async (
}

if (isGoogleError(e)) {
await handleGoogleError(req, res, userId, e);
await handleGoogleError(req, res, userId, e as GaxiosError);
} else {
const errInfo = assembleErrorInfo(e);
res.status(e.status || Status.INTERNAL_SERVER).send(errInfo);
Expand All @@ -104,20 +106,23 @@ export const handleExpressError = async (
};

const handleGoogleError = async (
req: Request | SessionRequest,
_req: Request | SessionRequest,
res: SessionResponse,
userId: string,
e: GaxiosError,
) => {
if (isInvalidGoogleToken(e)) {
await req.session?.revokeSession();
await userService.pruneGoogleData(userId);
webSocketServer.handleGoogleRevoked(userId);

// revoke specific sessions for this user
logger.debug(
`Invalid Google token for user: ${userId}\n\tsession revoked as result`,
logger.warn(
`Invalid Google token for user: ${userId}. Google data pruned and client notified.`,
);

res.status(Status.UNAUTHORIZED).send();
res.status(Status.UNAUTHORIZED).send({
code: GOOGLE_REVOKED,
message: "Google access revoked. Your Google data has been removed.",
});
return;
}

Expand Down
34 changes: 34 additions & 0 deletions packages/backend/src/common/errors/handlers/error.handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { GOOGLE_REVOKED } from "@core/constants/websocket.constants";
import { BaseError } from "@core/errors/errors.base";
import { Status } from "@core/errors/status.codes";
import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant";
import { handleExpressError } from "@backend/common/errors/handlers/error.express.handler";
import {
error,
errorHandler,
toClientErrorPayload,
} from "@backend/common/errors/handlers/error.handler";
import { UserError } from "@backend/common/errors/user/user.errors";
import { webSocketServer } from "@backend/servers/websocket/websocket.server";
import userService from "@backend/user/services/user.service";

describe("error.handler", () => {
describe("toClientErrorPayload", () => {
Expand Down Expand Up @@ -38,4 +44,32 @@ describe("error.handler", () => {
expect(Object.keys(payload)).toEqual(["result", "message"]);
});
});

describe("handleExpressError", () => {
it("returns 401 with GOOGLE_REVOKED payload when Google token is invalid", async () => {
const userId = "507f1f77bcf86cd799439011";
jest.spyOn(userService, "pruneGoogleData").mockResolvedValue();
jest.spyOn(webSocketServer, "handleGoogleRevoked");
jest.spyOn(errorHandler, "isOperational").mockReturnValue(true);
Comment on lines +49 to +53
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test spies on webSocketServer.handleGoogleRevoked but doesn't mock its implementation or assert it was called. Because webSocketServer is a singleton with internal connection state, calling the real method can make the test order-dependent/flaky. Mock the implementation (e.g., mockImplementation(() => undefined)) and add an expectation that it was invoked with the userId (and restore the spy after the test).

Copilot uses AI. Check for mistakes.

const send = jest.fn();
const res = {
header: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
send,
} as unknown as Parameters<typeof handleExpressError>[1];
const req = {
session: { getUserId: () => userId },
} as Parameters<typeof handleExpressError>[0];
(res as { req?: typeof req }).req = req;

await handleExpressError(req, res, invalidGrant400Error);

expect(res.status).toHaveBeenCalledWith(Status.UNAUTHORIZED);
expect(send).toHaveBeenCalledWith({
code: GOOGLE_REVOKED,
message: "Google access revoked. Your Google data has been removed.",
});
});
});
});
15 changes: 15 additions & 0 deletions packages/backend/src/common/services/gcal/gcal.util.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createGoogleError } from "../../../__tests__/mocks.gcal/errors/error.google.factory";
import { invalidGrant400Error } from "../../../__tests__/mocks.gcal/errors/error.google.invalidGrant";
import { invalidValueError } from "../../../__tests__/mocks.gcal/errors/error.google.invalidValue";
import { invalidSyncTokenError } from "../../../__tests__/mocks.gcal/errors/error.invalidSyncToken";
import {
getEmailFromUrl,
getGoogleErrorStatus,
isFullSyncRequired,
isInvalidGoogleToken,
isInvalidValue,
Expand All @@ -18,6 +20,19 @@ describe("Google Error Parsing", () => {
it("recognizes expired refresh token", () => {
expect(isInvalidGoogleToken(invalidGrant400Error)).toBe(true);
});
it("returns response status when present", () => {
expect(
getGoogleErrorStatus(
createGoogleError({ code: "500", responseStatus: 401 }),
),
).toBe(401);
});
it("falls back to the parsed gaxios code", () => {
expect(getGoogleErrorStatus(createGoogleError({ code: "410" }))).toBe(410);
});
it("returns undefined for non-google errors", () => {
expect(getGoogleErrorStatus(new Error("nope"))).toBeUndefined();
});
});

describe("Gaxios response parsing", () => {
Expand Down
39 changes: 26 additions & 13 deletions packages/backend/src/common/services/gcal/gcal.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,40 @@ export const getEmailFromUrl = (url: string) => {
};

// occurs when token expired or revoked
export const isInvalidGoogleToken = (e: GaxiosError | Error) => {
const is400 = "code" in e && e.code === "400";
const hasInvalidMsg = "message" in e && e.message === "invalid_grant";
const isInvalid = is400 && hasInvalidMsg;
export const isInvalidGoogleToken = (e: unknown) => {
if (!isGoogleError(e)) return false;

return isGoogleError(e) && isInvalid;
const err = e as GaxiosError;
const is400 = err.code === "400" || err.response?.status === 400;
const hasInvalidMsg = err.message === "invalid_grant";
const hasInvalidData = err.response?.data?.error === "invalid_grant";

return is400 && (hasInvalidMsg || hasInvalidData);
};

export const isGoogleError = (e: unknown) => {
return e instanceof GaxiosError;
return e instanceof GaxiosError || (e as any)?.name === "GaxiosError";
};

export const isFullSyncRequired = (e: GaxiosError | Error) => {
if (isGoogleError(e) && e.code) {
const codeStr = typeof e.code === "string" ? e.code : String(e.code);
if (parseInt(codeStr) === 410) {
return true;
}
export const getGoogleErrorStatus = (e: unknown) => {
if (!isGoogleError(e)) return undefined;

const err = e as GaxiosError;
const responseStatus = err.response?.status;

if (typeof responseStatus === "number") return responseStatus;
if (typeof err.code === "number") return err.code;

if (typeof err.code === "string") {
const code = Number.parseInt(err.code, 10);
if (!Number.isNaN(code)) return code;
}

return false;
return undefined;
};

export const isFullSyncRequired = (e: unknown) => {
return getGoogleErrorStatus(e) === 410;
};

export const isInvalidValue = (e: GaxiosError) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/servers/websocket/websocket.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EVENT_CHANGED,
EVENT_CHANGE_PROCESSED,
FETCH_USER_METADATA,
GOOGLE_REVOKED,
IMPORT_GCAL_END,
IMPORT_GCAL_START,
RESULT_IGNORED,
Expand Down Expand Up @@ -236,6 +237,10 @@ class WebSocketServer {
handleBackgroundSomedayChange(userId: string) {
return this.notifyUser(userId, SOMEDAY_EVENT_CHANGED);
}

handleGoogleRevoked(userId: string) {
return this.notifyUser(userId, GOOGLE_REVOKED);
}
}

export const webSocketServer = new WebSocketServer();
57 changes: 56 additions & 1 deletion packages/backend/src/sync/controllers/sync.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto";
import { DefaultEventsMap } from "socket.io";
import { Socket } from "socket.io-client";
import { faker } from "@faker-js/faker";
import { EVENT_CHANGED } from "@core/constants/websocket.constants";
import {
EVENT_CHANGED,
GOOGLE_REVOKED,
} from "@core/constants/websocket.constants";
import { Status } from "@core/errors/status.codes";
import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types";
import { Schema_User } from "@core/types/user.types";
Expand All @@ -23,12 +26,16 @@ import {
cleanupTestDb,
setupTestDb,
} from "@backend/__tests__/helpers/mock.db.setup";
import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant";
import { WatchError } from "@backend/common/errors/sync/watch.errors";
import gcalService from "@backend/common/services/gcal/gcal.service";
import mongoService from "@backend/common/services/mongo.service";
import { webSocketServer } from "@backend/servers/websocket/websocket.server";
import syncService from "@backend/sync/services/sync.service";
import * as syncQueries from "@backend/sync/util/sync.queries";
import { updateSync } from "@backend/sync/util/sync.queries";
import userMetadataService from "@backend/user/services/user-metadata.service";
import userService from "@backend/user/services/user.service";

describe("SyncController", () => {
const baseDriver = new BaseDriver();
Expand Down Expand Up @@ -184,6 +191,54 @@ describe("SyncController", () => {

expect(response.text).toEqual("IGNORED");
});

it("should prune Google data, notify client via websocket, and return structured response when user revokes access", async () => {
const { user } = await UtilDriver.setupTestUser();
const userId = user._id.toString();

const watch = await mongoService.watch.findOne({
user: userId,
gCalendarId: { $ne: Resource_Sync.CALENDAR },
});

expect(watch).toBeDefined();
expect(watch).not.toBeNull();

const handleGcalNotificationSpy = jest
.spyOn(syncService, "handleGcalNotification")
.mockRejectedValue(invalidGrant400Error);

const pruneGoogleDataSpy = jest
.spyOn(userService, "pruneGoogleData")
.mockResolvedValue();

const handleGoogleRevokedSpy = jest.spyOn(
webSocketServer,
"handleGoogleRevoked",
);

const response = await syncDriver.handleGoogleNotification(
{
resource: Resource_Sync.EVENTS,
channelId: watch!._id,
resourceId: watch!.resourceId,
resourceState: XGoogleResourceState.EXISTS,
expiration: watch!.expiration,
},
Status.GONE,
);

expect(response.body).toEqual({
code: GOOGLE_REVOKED,
message: "User revoked access, pruned Google data",
});
expect(pruneGoogleDataSpy).toHaveBeenCalledWith(userId);
expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId);

handleGcalNotificationSpy.mockRestore();
pruneGoogleDataSpy.mockRestore();
handleGoogleRevokedSpy.mockRestore();
});
});

describe("importGCal: ", () => {
Expand Down
14 changes: 9 additions & 5 deletions packages/backend/src/sync/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express";
import { ObjectId } from "mongodb";
import { ZodError } from "zod/v4";
import { COMPASS_RESOURCE_HEADER } from "@core/constants/core.constants";
import { GOOGLE_REVOKED } from "@core/constants/websocket.constants";
import { Status } from "@core/errors/status.codes";
import { Logger } from "@core/logger/winston.logger";
import {
Expand All @@ -18,6 +19,7 @@ import {
isInvalidGoogleToken,
} from "@backend/common/services/gcal/gcal.utils";
import mongoService from "@backend/common/services/mongo.service";
import { webSocketServer } from "@backend/servers/websocket/websocket.server";
import syncService from "@backend/sync/services/sync.service";
import { getSync } from "@backend/sync/util/sync.queries";
import userService from "@backend/user/services/user.service";
Expand Down Expand Up @@ -87,11 +89,13 @@ export class SyncController {
`Cleaning data after this user revoked access: ${userId}`,
);

await userService.deleteCompassDataForUser(userId, false);
await userService.pruneGoogleData(userId);
webSocketServer.handleGoogleRevoked(userId);

res
.status(Status.GONE)
.send("User revoked access, deleted all data");
res.status(Status.GONE).send({
code: GOOGLE_REVOKED,
message: "User revoked access, pruned Google data",
});

return;
}
Expand All @@ -103,7 +107,7 @@ export class SyncController {
res.status(Status.GONE).send(msg);

return;
} else if (isFullSyncRequired(e as Error) && userId) {
} else if (isFullSyncRequired(e) && userId) {
// do not await this call
userService
.restartGoogleCalendarSync(userId, { force: true })
Expand Down
Loading