diff --git a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts index 49856fb41..4c4ad7560 100644 --- a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts +++ b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts @@ -8,12 +8,12 @@ export type ParsedReconnectGoogleParams = { }; export function parseReconnectGoogleParams( - sessionUserId: string, + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): ParsedReconnectGoogleParams { const cUserId = zObjectId - .parse(sessionUserId, { error: () => "Invalid credentials" }) + .parse(compassUserId, { error: () => "Invalid credentials" }) .toString(); StringV4Schema.parse(gUser.sub, { error: () => "Invalid Google user ID" }); const refreshToken = StringV4Schema.parse(oAuthTokens.refresh_token, { diff --git a/packages/backend/src/auth/services/compass.auth.service.test.ts b/packages/backend/src/auth/services/compass.auth.service.test.ts index f08699698..8fdba0fbe 100644 --- a/packages/backend/src/auth/services/compass.auth.service.test.ts +++ b/packages/backend/src/auth/services/compass.auth.service.test.ts @@ -1,5 +1,8 @@ import { type Credentials } from "google-auth-library"; +import { ObjectId } from "mongodb"; import { faker } from "@faker-js/faker"; +import { Resource_Sync } from "@core/types/sync.types"; +import { WatchSchema } from "@core/types/watch.types"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { cleanupCollections, @@ -8,17 +11,146 @@ import { } from "@backend/__tests__/helpers/mock.db.setup"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; +import syncService from "@backend/sync/services/sync.service"; +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"; import compassAuthService from "./compass.auth.service"; +const buildGoogleSignInSuccess = (userId: string, googleId: string) => ({ + providerUser: UserDriver.generateGoogleUser({ sub: googleId }), + oAuthTokens: { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + } as Pick, + createdNewRecipeUser: false, + recipeUserId: userId, + loginMethodsLength: 1, + sessionUserId: null, +}); + +const createActiveWatch = async (userId: string, gCalendarId: string) => { + await mongoService.watch.insertOne( + WatchSchema.parse({ + _id: new ObjectId(), + user: userId, + resourceId: faker.string.uuid(), + expiration: new Date(Date.now() + 60_000), + gCalendarId, + createdAt: new Date(), + }), + ); +}; + describe("CompassAuthService", () => { beforeAll(initSupertokens); beforeEach(setupTestDb); beforeEach(cleanupCollections); afterAll(cleanupTestDb); - describe("reconnectGoogleForSession", () => { + describe("determineGoogleAuthMode", () => { + it("returns reconnect_repair when the stored refresh token is missing", async () => { + const user = await UserDriver.createUser({ + withGoogleRefreshToken: false, + }); + const success = buildGoogleSignInSuccess( + user._id.toString(), + user.google?.googleId ?? faker.string.uuid(), + ); + + const result = await compassAuthService.determineGoogleAuthMode(success); + + expect(result).toMatchObject({ + authMode: "reconnect_repair", + cUserId: user._id.toString(), + hasStoredRefreshTokenBefore: false, + isReconnectRepair: true, + }); + }); + + it("returns signin_incremental when the user has a healthy incremental sync", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + await updateSync(Resource_Sync.CALENDAR, userId, user.email, { + nextSyncToken: faker.string.uuid(), + }); + await updateSync(Resource_Sync.EVENTS, userId, user.email, { + nextSyncToken: faker.string.uuid(), + }); + await createActiveWatch(userId, Resource_Sync.CALENDAR); + await createActiveWatch(userId, user.email); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "completed", incrementalGCalSync: "completed" }, + }, + }); + + const result = await compassAuthService.determineGoogleAuthMode( + buildGoogleSignInSuccess( + userId, + user.google?.googleId ?? faker.string.uuid(), + ), + ); + + expect(result).toMatchObject({ + authMode: "signin_incremental", + cUserId: userId, + hasStoredRefreshTokenBefore: true, + isReconnectRepair: false, + }); + }); + + it("returns signup for a brand-new Google user", async () => { + const newUserId = faker.database.mongodbObjectId(); + + const result = await compassAuthService.determineGoogleAuthMode({ + providerUser: UserDriver.generateGoogleUser(), + oAuthTokens: { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + }, + createdNewRecipeUser: true, + recipeUserId: newUserId, + loginMethodsLength: 1, + sessionUserId: null, + }); + + expect(result).toMatchObject({ + authMode: "signup", + cUserId: newUserId, + hasStoredRefreshTokenBefore: false, + isReconnectRepair: false, + }); + }); + + it("returns reconnect_repair when sync state is not incremental-ready", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + await userMetadataService.updateUserMetadata({ + userId, + data: { + sync: { importGCal: "completed", incrementalGCalSync: "completed" }, + }, + }); + + const result = await compassAuthService.determineGoogleAuthMode( + buildGoogleSignInSuccess( + userId, + user.google?.googleId ?? faker.string.uuid(), + ), + ); + + expect(result).toMatchObject({ + authMode: "reconnect_repair", + cUserId: userId, + hasStoredRefreshTokenBefore: true, + isReconnectRepair: true, + }); + }); + }); + + describe("repairGoogleConnection", () => { it("relinks Google to the current Compass user and schedules a full reimport", async () => { const user = await UserDriver.createUser(); const sessionUserId = user._id.toString(); @@ -36,7 +168,7 @@ describe("CompassAuthService", () => { await userService.pruneGoogleData(sessionUserId); - const result = await compassAuthService.reconnectGoogleForSession( + const result = await compassAuthService.repairGoogleConnection( sessionUserId, gUser, oAuthTokens, @@ -79,7 +211,7 @@ describe("CompassAuthService", () => { await userService.pruneGoogleData(sessionUserId); await expect( - compassAuthService.reconnectGoogleForSession( + compassAuthService.repairGoogleConnection( sessionUserId, gUser, oAuthTokens, @@ -93,4 +225,42 @@ describe("CompassAuthService", () => { restartSpy.mockRestore(); }); }); + + describe("googleSignin", () => { + it("queues a full repair instead of incremental sync when the user was revoked", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const restartSpy = jest + .spyOn(userService, "restartGoogleCalendarSync") + .mockResolvedValue(); + const incrementalSpy = jest + .spyOn(syncService, "importIncremental") + .mockResolvedValue(undefined as never); + const oAuthTokens: Pick = { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + }; + + await userService.pruneGoogleData(userId); + + const result = await compassAuthService.googleSignin( + UserDriver.generateGoogleUser({ + sub: user.google?.googleId, + picture: faker.image.url(), + }), + oAuthTokens, + ); + + const metadata = await userMetadataService.fetchUserMetadata(userId); + + expect(result).toEqual({ cUserId: userId }); + expect(restartSpy).toHaveBeenCalledWith(userId); + expect(incrementalSpy).not.toHaveBeenCalled(); + expect(metadata.sync?.importGCal).toBe("restart"); + expect(metadata.sync?.incrementalGCalSync).toBe("restart"); + + restartSpy.mockRestore(); + incrementalSpy.mockRestore(); + }); + }); }); diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index 67c3e351e..b89e76d52 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -6,9 +6,12 @@ import { mapCompassUserToEmailSubscriber } from "@core/mappers/subscriber/map.su import { StringV4Schema, zObjectId } from "@core/types/type.utils"; import { parseReconnectGoogleParams } from "@backend/auth/schemas/reconnect-google.schemas"; import GoogleAuthService from "@backend/auth/services/google/google.auth.service"; +import { + type GoogleAuthDecision, + type GoogleSignInSuccess, +} from "@backend/auth/services/google/google.auth.success.service"; import { ENV } from "@backend/common/constants/env.constants"; import { isMissingUserTagId } from "@backend/common/constants/env.util"; -import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; @@ -31,26 +34,80 @@ class CompassAuthService { }); }; - determineAuthMethod = async (gUserId: string) => { - const user = await findCompassUserBy("google.googleId", gUserId); + private assessGoogleConnection = async (userId: string) => { + const user = await findCompassUserBy("_id", userId); if (!user) { - return { authMethod: "signup", user: null }; + throw new Error( + `Could not resolve Compass user for Google auth: ${userId}`, + ); } - const userId = user._id.toString(); + const hasStoredRefreshTokenBefore = Boolean(user.google?.gRefreshToken); const sync = await getSync({ userId }); - if (!sync) { - throw error( - SyncError.NoSyncRecordForUser, - "Did not verify sync record for user", + const isIncrementalReady = Boolean(sync && canDoIncrementalSync(sync)); + const googleMetadata = + await userMetadataService.assessGoogleMetadata(userId); + const isHealthy = + googleMetadata.connectionStatus === "connected" && + googleMetadata.syncStatus === "healthy"; + + return { + hasStoredRefreshTokenBefore, + isIncrementalReady, + isHealthy, + needsRepair: + !hasStoredRefreshTokenBefore || !isIncrementalReady || !isHealthy, + }; + }; + + determineGoogleAuthMode = async ( + success: GoogleSignInSuccess, + ): Promise => { + const { + createdNewRecipeUser, + loginMethodsLength, + providerUser, + recipeUserId, + sessionUserId, + } = success; + const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + + if (isNewUser) { + return { + authMode: "signup", + cUserId: recipeUserId, + hasStoredRefreshTokenBefore: false, + hasSession: sessionUserId !== null, + isReconnectRepair: false, + }; + } + + const googleUserId = StringV4Schema.parse(providerUser.sub, { + error: () => "Invalid Google user ID", + }); + const existingUser = + (await findCompassUserBy("_id", recipeUserId)) ?? + (await findCompassUserBy("google.googleId", googleUserId)); + + if (!existingUser) { + throw new Error( + `Could not resolve Compass user for Google auth: ${recipeUserId}`, ); } - const canLogin = canDoIncrementalSync(sync); - const authMethod = user && canLogin ? "login" : "signup"; + const cUserId = existingUser._id.toString(); + const assessment = await this.assessGoogleConnection(cUserId); - return { authMethod, user }; + return { + authMode: assessment.needsRepair + ? "reconnect_repair" + : "signin_incremental", + cUserId, + hasStoredRefreshTokenBefore: assessment.hasStoredRefreshTokenBefore, + hasSession: sessionUserId !== null, + isReconnectRepair: assessment.needsRepair, + }; }; createSessionForUser = async (cUserId: string) => { @@ -126,8 +183,8 @@ class CompassAuthService { return user; } - async reconnectGoogleForSession( - sessionUserId: string, + async repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ) { @@ -135,7 +192,7 @@ class CompassAuthService { cUserId, gUser: validatedGUser, refreshToken, - } = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens); + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); await userService.reconnectGoogleCredentials( cUserId, @@ -181,6 +238,20 @@ class CompassAuthService { const cUserId = zObjectId .parse(user?._id, { error: () => "Invalid credentials" }) .toString(); + const assessment = await this.assessGoogleConnection(cUserId); + + if (assessment.needsRepair) { + await userMetadataService.updateUserMetadata({ + userId: cUserId, + data: { + sync: { importGCal: "restart", incrementalGCalSync: "restart" }, + }, + }); + + this.restartGoogleCalendarSyncInBackground(cUserId); + + return { cUserId }; + } // start incremental sync - do not await const gAuthClient = new GoogleAuthService(); diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts index 40dfe39cd..42c52bc54 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts @@ -1,6 +1,7 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; import { faker } from "@faker-js/faker"; import { + type GoogleAuthDecision, type GoogleSignInSuccess, type GoogleSignInSuccessAuthService, handleGoogleAuth, @@ -26,12 +27,22 @@ function makeOAuthTokens(): Pick< } function createMockAuthService(): GoogleSignInSuccessAuthService & { - reconnectGoogleForSession: jest.Mock; + determineGoogleAuthMode: jest.Mock; + repairGoogleConnection: jest.Mock; googleSignup: jest.Mock; googleSignin: jest.Mock; } { + const defaultDecision: GoogleAuthDecision = { + authMode: "signin_incremental", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: true, + hasSession: false, + isReconnectRepair: false, + }; + return { - reconnectGoogleForSession: jest + determineGoogleAuthMode: jest.fn().mockResolvedValue(defaultDecision), + repairGoogleConnection: jest .fn() .mockResolvedValue({ cUserId: "reconnect-id" }), googleSignup: jest.fn().mockResolvedValue({ cUserId: "signup-id" }), @@ -41,11 +52,18 @@ function createMockAuthService(): GoogleSignInSuccessAuthService & { describe("handleGoogleSignInSuccess", () => { describe("reconnect path", () => { - it("calls reconnectGoogleForSession when sessionUserId is set", async () => { + it("calls repairGoogleConnection when auth mode is reconnect_repair", async () => { const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); const sessionUserId = faker.database.mongodbObjectId(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "reconnect_repair", + cUserId: sessionUserId, + hasStoredRefreshTokenBefore: false, + hasSession: true, + isReconnectRepair: true, + }); const success: GoogleSignInSuccess = { providerUser, @@ -58,8 +76,9 @@ describe("handleGoogleSignInSuccess", () => { await handleGoogleAuth(success, authService); - expect(authService.reconnectGoogleForSession).toHaveBeenCalledTimes(1); - expect(authService.reconnectGoogleForSession).toHaveBeenCalledWith( + expect(authService.determineGoogleAuthMode).toHaveBeenCalledWith(success); + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.repairGoogleConnection).toHaveBeenCalledWith( sessionUserId, providerUser, oAuthTokens, @@ -75,6 +94,13 @@ describe("handleGoogleSignInSuccess", () => { const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); const recipeUserId = faker.database.mongodbObjectId(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signup", + cUserId: recipeUserId, + hasStoredRefreshTokenBefore: false, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser, @@ -93,12 +119,19 @@ describe("handleGoogleSignInSuccess", () => { oAuthTokens.refresh_token, recipeUserId, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignin).not.toHaveBeenCalled(); }); it("throws when refresh_token is missing for new user", async () => { const authService = createMockAuthService(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signup", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: false, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser: makeProviderUser(), oAuthTokens: { access_token: faker.internet.jwt() }, @@ -121,6 +154,13 @@ describe("handleGoogleSignInSuccess", () => { const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signin_incremental", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: true, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser, @@ -138,7 +178,7 @@ describe("handleGoogleSignInSuccess", () => { providerUser, oAuthTokens, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignup).not.toHaveBeenCalled(); }); @@ -146,6 +186,13 @@ describe("handleGoogleSignInSuccess", () => { const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); + authService.determineGoogleAuthMode.mockResolvedValue({ + authMode: "signin_incremental", + cUserId: faker.database.mongodbObjectId(), + hasStoredRefreshTokenBefore: true, + hasSession: false, + isReconnectRepair: false, + }); const success: GoogleSignInSuccess = { providerUser, diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.ts b/packages/backend/src/auth/services/google/google.auth.success.service.ts index 0078a9479..88e5afd1a 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.ts @@ -1,4 +1,20 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; +import { Logger } from "@core/logger/winston.logger"; + +const logger = Logger("app:google.auth.success.service"); + +export type GoogleAuthMode = + | "signup" + | "signin_incremental" + | "reconnect_repair"; + +export type GoogleAuthDecision = { + authMode: GoogleAuthMode; + cUserId: string | null; + hasStoredRefreshTokenBefore: boolean; + hasSession: boolean; + isReconnectRepair: boolean; +}; export type GoogleSignInSuccess = { providerUser: TokenPayload; @@ -10,8 +26,11 @@ export type GoogleSignInSuccess = { }; export interface GoogleSignInSuccessAuthService { - reconnectGoogleForSession( - sessionUserId: string, + determineGoogleAuthMode( + success: GoogleSignInSuccess, + ): Promise; + repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): Promise<{ cUserId: string }>; @@ -36,21 +55,22 @@ export async function handleGoogleAuth( createdNewRecipeUser, recipeUserId, loginMethodsLength, - sessionUserId, } = success; + const decision = await authService.determineGoogleAuthMode(success); - if (sessionUserId !== null) { - await authService.reconnectGoogleForSession( - sessionUserId, - providerUser, - oAuthTokens, - ); - return; - } - - const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + logger.info( + `Resolved Google auth mode: ${JSON.stringify({ + auth_mode: decision.authMode, + createdNewRecipeUser, + hasStoredRefreshTokenBefore: decision.hasStoredRefreshTokenBefore, + hasSession: decision.hasSession, + isReconnectRepair: decision.isReconnectRepair, + recipeUserId, + loginMethodsLength, + })}`, + ); - if (isNewUser) { + if (decision.authMode === "signup") { const refreshToken = oAuthTokens.refresh_token; if (!refreshToken) { throw new Error("Refresh token expected for new user sign-up"); @@ -59,5 +79,18 @@ export async function handleGoogleAuth( return; } + if (decision.authMode === "reconnect_repair") { + if (!decision.cUserId) { + throw new Error("Compass user ID expected for Google reconnect repair"); + } + + await authService.repairGoogleConnection( + decision.cUserId, + providerUser, + oAuthTokens, + ); + return; + } + await authService.googleSignin(providerUser, oAuthTokens); } diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index d27634602..e9ba4e2f1 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -20,7 +20,6 @@ import { type CreateGoogleSignInResponse, type ThirdPartySignInUpInput, createGoogleSignInSuccess, - getGoogleAuthIntent, } from "@backend/common/middleware/supertokens.middleware.util"; import mongoService from "@backend/common/services/mongo.service"; import syncService from "@backend/sync/services/sync.service"; @@ -129,12 +128,8 @@ export const initSupertokens = () => { const response = await originalImplementation.signInUpPOST(input); - const body = (await input.options.req.getJSONBody()) as { - googleAuthIntent?: unknown; - }; const success = createGoogleSignInSuccess( response as CreateGoogleSignInResponse, - getGoogleAuthIntent(body?.googleAuthIntent), input.session?.getUserId() ?? null, ); diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts index fdc968ef2..9b4034c57 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.test.ts @@ -1,61 +1,8 @@ import { type TokenPayload } from "google-auth-library"; import { faker } from "@faker-js/faker"; -import { - createGoogleSignInSuccess, - resolveGoogleSessionUserId, -} from "@backend/common/middleware/supertokens.middleware.util"; +import { createGoogleSignInSuccess } from "@backend/common/middleware/supertokens.middleware.util"; describe("supertokens.middleware.util", () => { - describe("resolveGoogleSessionUserId", () => { - it("prefers the current session when one exists", () => { - const sessionUserId = faker.database.mongodbObjectId(); - const recipeUserId = faker.database.mongodbObjectId(); - - expect( - resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent: "reconnect", - createdNewRecipeUser: false, - recipeUserId, - }), - ).toBe(sessionUserId); - }); - - it("uses the recipe user id for reconnects without a session", () => { - const recipeUserId = faker.database.mongodbObjectId(); - - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - googleAuthIntent: "reconnect", - createdNewRecipeUser: false, - recipeUserId, - }), - ).toBe(recipeUserId); - }); - - it("keeps normal returning users on the sign-in path without reconnect intent", () => { - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - createdNewRecipeUser: false, - recipeUserId: faker.database.mongodbObjectId(), - }), - ).toBeNull(); - }); - - it("does not force reconnect behavior for new users", () => { - expect( - resolveGoogleSessionUserId({ - sessionUserId: null, - googleAuthIntent: "reconnect", - createdNewRecipeUser: true, - recipeUserId: faker.database.mongodbObjectId(), - }), - ).toBeNull(); - }); - }); - describe("createGoogleSignInSuccess", () => { it("returns null for non-OK responses", () => { expect( @@ -65,8 +12,9 @@ describe("supertokens.middleware.util", () => { ).toBeNull(); }); - it("embeds reconnect fallback user id into the auth success payload", () => { + it("preserves the current session user id in the auth success payload", () => { const recipeUserId = faker.database.mongodbObjectId(); + const sessionUserId = faker.database.mongodbObjectId(); const success = createGoogleSignInSuccess( { status: "OK", @@ -86,14 +34,13 @@ describe("supertokens.middleware.util", () => { loginMethods: [{}], }, } as Parameters[0], - "reconnect", - null, + sessionUserId, ); expect(success).toMatchObject({ createdNewRecipeUser: false, recipeUserId, - sessionUserId: recipeUserId, + sessionUserId, loginMethodsLength: 1, }); }); diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index beb365519..698cf1f6a 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -1,6 +1,5 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; import type { APIInterface } from "supertokens-node/recipe/thirdparty/types"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import type { GoogleSignInSuccess } from "@backend/auth/services/google/google.auth.success.service"; type ThirdPartySignInUpPost = NonNullable; @@ -20,41 +19,8 @@ export type CreateGoogleSignInResponse = | { status: Exclude } | GoogleThirdPartySignInUpSuccess; -export function getGoogleAuthIntent( - value: unknown, -): GoogleAuthIntent | undefined { - if (value === "connect" || value === "reconnect") { - return value; - } - - return undefined; -} - -export function resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser, - recipeUserId, -}: { - sessionUserId: string | null; - googleAuthIntent?: GoogleAuthIntent; - createdNewRecipeUser: boolean; - recipeUserId: string; -}): string | null { - if (sessionUserId) { - return sessionUserId; - } - - if (googleAuthIntent === "reconnect" && !createdNewRecipeUser) { - return recipeUserId; - } - - return null; -} - export function createGoogleSignInSuccess( response: CreateGoogleSignInResponse, - googleAuthIntent?: GoogleAuthIntent, sessionUserId: string | null = null, ): GoogleSignInSuccess | null { if (response.status !== "OK") return null; @@ -65,11 +31,6 @@ export function createGoogleSignInSuccess( createdNewRecipeUser: response.createdNewRecipeUser, recipeUserId: response.user.id, loginMethodsLength: response.user.loginMethods.length, - sessionUserId: resolveGoogleSessionUserId({ - sessionUserId, - googleAuthIntent, - createdNewRecipeUser: response.createdNewRecipeUser, - recipeUserId: response.user.id, - }), + sessionUserId, }; } diff --git a/packages/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts deleted file mode 100644 index f5b13809b..000000000 --- a/packages/core/src/types/google-auth.types.ts +++ /dev/null @@ -1 +0,0 @@ -export type GoogleAuthIntent = "connect" | "reconnect"; diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts index 0e154fd25..9f6aad3d1 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.test.ts @@ -69,9 +69,7 @@ describe("useConnectGoogle", () => { it("returns checking state when metadata is still loading", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Checking Google Calendar…", ); @@ -107,9 +105,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); @@ -143,9 +139,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Google Calendar Connected", ); @@ -180,9 +174,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -220,9 +212,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Syncing Google Calendar…"); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.commandAction.onSelect).toBeUndefined(); @@ -255,9 +245,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Repair Google Calendar"); expect(result.current.commandAction.isDisabled).toBe(false); expect(result.current.sidebarStatus.icon).toBe("CloudWarningIcon"); @@ -316,9 +304,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.isDisabled).toBe(true); expect(result.current.sidebarStatus.icon).toBe("SpinnerIcon"); expect(result.current.sidebarStatus.isDisabled).toBe(true); @@ -349,9 +335,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe( "Reconnect Google Calendar", ); @@ -382,9 +366,7 @@ describe("useConnectGoogle", () => { const { result } = renderHook(() => useConnectGoogle()); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: undefined, - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect(result.current.commandAction.label).toBe("Connect Google Calendar"); expect(result.current.sidebarStatus.icon).toBe("CloudArrowUpIcon"); }); diff --git a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts index bd1b19fc3..2589a392b 100644 --- a/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/oauth/useConnectGoogle.ts @@ -201,13 +201,10 @@ export const useConnectGoogle = () => { ); const connectionStatus = googleMetadata?.connectionStatus ?? "not_connected"; const syncStatus = googleMetadata?.syncStatus ?? "none"; - const { login } = useGoogleAuth({ - googleAuthIntent: - connectionStatus === "reconnect_required" ? "reconnect" : undefined, - }); + const { login } = useGoogleAuth(); const onOpenGoogleAuth = useCallback(() => { - login(); + void login(); dispatch(settingsSlice.actions.closeCmdPalette()); }, [dispatch, login]); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts index 1797467e5..d7d83857e 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.test.ts @@ -1,3 +1,4 @@ +import { toast } from "react-toastify"; import { renderHook, waitFor } from "@testing-library/react"; import { authenticate, @@ -9,6 +10,7 @@ import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; import { markUserAsAuthenticated } from "@web/auth/state/auth.state.util"; import { useGoogleLogin } from "@web/components/oauth/google/useGoogleLogin"; import { type SignInUpInput } from "@web/components/oauth/ouath.types"; +import { useAppDispatch } from "@web/store/store.hooks"; // Mock dependencies jest.mock("@web/auth/google/google.auth.util"); @@ -43,14 +45,14 @@ const mockUseGoogleLogin = useGoogleLogin as jest.MockedFunction< const mockRefreshUserMetadata = refreshUserMetadata as jest.MockedFunction< typeof refreshUserMetadata >; -const storeHooksMock = jest.requireMock("@web/store/store.hooks") as { - useAppDispatch: jest.Mock; -}; -const mockUseAppDispatch = storeHooksMock.useAppDispatch; +const mockUseAppDispatch = useAppDispatch as jest.MockedFunction< + typeof useAppDispatch +>; const mockMarkUserAsAuthenticated = markUserAsAuthenticated as jest.MockedFunction< typeof markUserAsAuthenticated >; +const mockToast = jest.mocked(toast); describe("useGoogleAuth", () => { const mockSetAuthenticated = jest.fn(); @@ -152,44 +154,6 @@ describe("useGoogleAuth", () => { expect(mockRefreshUserMetadata).toHaveBeenCalledTimes(1); }); - it("passes reconnect intent through authentication when requested", async () => { - let onSuccessCallback: ((data: SignInUpInput) => Promise) | undefined; - - mockUseGoogleLogin.mockImplementation(({ onSuccess }) => { - onSuccessCallback = onSuccess; - return { - login: mockLogin, - loading: false, - data: null, - }; - }); - - renderHook(() => useGoogleAuth({ googleAuthIntent: "reconnect" })); - - if (onSuccessCallback) { - await onSuccessCallback({ - clientType: "web", - thirdPartyId: "google", - redirectURIInfo: { - redirectURIOnProviderDashboard: "", - redirectURIQueryParams: { - code: "test-auth-code", - scope: "email profile", - state: undefined, - }, - }, - }); - } - - await waitFor(() => { - expect(mockAuthenticate).toHaveBeenCalledWith( - expect.objectContaining({ - googleAuthIntent: "reconnect", - }), - ); - }); - }); - describe("onStart callback", () => { it("shows overlay immediately when login starts and clears session-expired toast", () => { mockUseGoogleLogin.mockReturnValue({ @@ -201,7 +165,7 @@ describe("useGoogleAuth", () => { const { result } = renderHook(() => useGoogleAuth()); // Simulate login start - result.current.login(); + void result.current.login(); expect(mockDispatchFn).toHaveBeenCalledWith( expect.objectContaining({ type: "auth/startAuthenticating" }), @@ -212,8 +176,7 @@ describe("useGoogleAuth", () => { payload: true, }), ); - const { toast } = jest.requireMock("react-toastify"); - expect(toast.dismiss).toHaveBeenCalledWith("session-expired-api"); + expect(mockToast.dismiss).toHaveBeenCalledWith("session-expired-api"); }); }); diff --git a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts index f3c8b06ba..a5fcc3684 100644 --- a/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts +++ b/packages/web/src/auth/hooks/oauth/useGoogleAuth.ts @@ -1,6 +1,5 @@ import { batch } from "react-redux"; import { toast } from "react-toastify"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; import { isGooglePopupClosedError } from "@web/auth/google/google-oauth-error.util"; import { authenticate, @@ -15,7 +14,6 @@ import { SESSION_EXPIRED_TOAST_ID, dismissErrorToast, } from "@web/common/utils/toast/error-toast.util"; -import { type SignInUpInput } from "@web/components/oauth/ouath.types"; import { authError, authSuccess, @@ -28,14 +26,9 @@ import { } from "@web/ducks/events/slices/sync.slice"; import { useAppDispatch } from "@web/store/store.hooks"; -interface UseGoogleAuthOptions { - googleAuthIntent?: GoogleAuthIntent; -} - -export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { +export function useGoogleAuth() { const dispatch = useAppDispatch(); const { setAuthenticated } = useSession(); - const { googleAuthIntent } = options; const googleLogin = useGoogleAuthWithOverlay({ onStart: () => { @@ -46,11 +39,7 @@ export function useGoogleAuth(options: UseGoogleAuthOptions = {}) { }, onSuccess: async (data) => { try { - const authPayload: SignInUpInput = - googleAuthIntent === "reconnect" - ? { ...data, googleAuthIntent } - : data; - const authResult = await authenticate(authPayload); + const authResult = await authenticate(data); if (!authResult.success) { console.error(authResult.error); dispatch( diff --git a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx index 4abb50ccb..fab6c8ae9 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.test.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.test.tsx @@ -26,9 +26,7 @@ describe("SessionExpiredToast", () => { it("renders session-expired message and reconnect button", () => { render(); - expect(mockUseGoogleAuth).toHaveBeenCalledWith({ - googleAuthIntent: "reconnect", - }); + expect(mockUseGoogleAuth).toHaveBeenCalledWith(); expect( screen.getByText("Google Calendar connection expired. Please reconnect."), ).toBeInTheDocument(); diff --git a/packages/web/src/common/utils/toast/session-expired.toast.tsx b/packages/web/src/common/utils/toast/session-expired.toast.tsx index eb6d1f52a..1df79103b 100644 --- a/packages/web/src/common/utils/toast/session-expired.toast.tsx +++ b/packages/web/src/common/utils/toast/session-expired.toast.tsx @@ -6,10 +6,10 @@ interface SessionExpiredToastProps { } export const SessionExpiredToast = ({ toastId }: SessionExpiredToastProps) => { - const { login } = useGoogleAuth({ googleAuthIntent: "reconnect" }); + const { login } = useGoogleAuth(); const handleReconnect = () => { - login(); + void login(); toast.dismiss(toastId); }; diff --git a/packages/web/src/components/oauth/ouath.types.ts b/packages/web/src/components/oauth/ouath.types.ts index eea6e7479..04c745cde 100644 --- a/packages/web/src/components/oauth/ouath.types.ts +++ b/packages/web/src/components/oauth/ouath.types.ts @@ -1,10 +1,8 @@ import { type CodeResponse } from "@react-oauth/google"; -import { type GoogleAuthIntent } from "@core/types/google-auth.types"; export interface SignInUpInput { thirdPartyId: string; clientType: "web"; - googleAuthIntent?: GoogleAuthIntent; redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: Omit< diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index df24eee4e..9ebc9b1cc 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -393,6 +393,33 @@ describe("useGcalSync", () => { expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); expect(importGCalSlice.actions.setImportError).not.toHaveBeenCalled(); }); + + it("clears pending import when reconnect metadata settles in attention", () => { + awaitingValue = true; + + let metadataHandler: ((metadata: unknown) => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === USER_METADATA) { + metadataHandler = handler; + } + }); + + renderHook(() => useGcalSync()); + + metadataHandler?.({ + sync: { importGCal: "completed" }, + google: { connectionStatus: "connected", syncStatus: "attention" }, + }); + + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.importing(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + importGCalSlice.actions.setImportError( + "Google Calendar still needs repair after reconnect.", + ), + ); + }); }); describe("import flow interaction", () => { diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 18e452bc8..02ca49684 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -83,11 +83,21 @@ export const useGcalSync = () => { const importStatus = metadata.sync?.importGCal; const isBackendImporting = importStatus === "importing"; const shouldAutoImport = importStatus === "restart"; + const isAttentionAfterReconnect = + metadata.google?.connectionStatus === "connected" && + metadata.google?.syncStatus === "attention"; dispatch(userMetadataSlice.actions.set(metadata)); if (isImportPendingRef.current) { - if (isBackendImporting) { + if (isAttentionAfterReconnect) { + dispatch(importGCalSlice.actions.importing(false)); + dispatch( + importGCalSlice.actions.setImportError( + "Google Calendar still needs repair after reconnect.", + ), + ); + } else if (isBackendImporting) { dispatch(importGCalSlice.actions.importing(true)); } else if (importStatus === "completed") { dispatch(importGCalSlice.actions.importing(false)); diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx index c0b8fefcb..47bb597d3 100644 --- a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx +++ b/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx @@ -514,6 +514,41 @@ describe("GCal Re-Authentication Flow", () => { ).not.toBeInTheDocument(); expect(store.getState().sync.importGCal.isImportPending).toBe(false); }); + + it("hides spinner and stores an error when reconnect lands in attention", async () => { + const store = createTestStore({ isImportPending: true, importing: true }); + + render( + + + + + , + ); + + await waitFor(() => { + expect(metadataCallback).toBeDefined(); + }); + + await act(async () => { + metadataCallback?.({ + sync: { importGCal: "completed" }, + google: { connectionStatus: "connected", syncStatus: "attention" }, + }); + }); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + expect( + screen.queryByText("Importing your Google Calendar events..."), + ).not.toBeInTheDocument(); + expect(store.getState().sync.importGCal.isImportPending).toBe(false); + expect(store.getState().sync.importGCal.importError).toBe( + "Google Calendar still needs repair after reconnect.", + ); + }); }); describe("Race condition handling (ref pattern)", () => {