Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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,2 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "disconnectedAt" TIMESTAMP(3);
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ model Account {
refreshTokenExpiresAt DateTime?
access_token String? @db.Text
expires_at DateTime? @default(now())
disconnectedAt DateTime? // When OAuth tokens were invalidated (password change, revoked access)
token_type String?
scope String?
id_token String? @db.Text
Expand Down
12 changes: 11 additions & 1 deletion apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { MeetingBriefingData } from "@/utils/meeting-briefs/gather-context";

vi.mock("server-only", () => ({}));
vi.mock("@/env", () => ({
env: {
PERPLEXITY_API_KEY: "test-key",
DEFAULT_LLM_PROVIDER: "openai",
},
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
vi.mock("@/utils/llms/model", () => ({ getModel: vi.fn() }));
vi.mock("@/utils/llms", () => ({ createGenerateObject: vi.fn() }));
vi.mock("@/utils/stringify-email", () => ({
Expand All @@ -22,6 +28,10 @@ vi.doUnmock("@/utils/date");

import { buildPrompt } from "./generate-briefing";

beforeEach(() => {
vi.clearAllMocks();
});

describe("buildPrompt timezone handling", () => {
it("formats past meeting times in the user's timezone (not UTC)", () => {
// This test documents the timezone bug fix:
Expand Down
91 changes: 87 additions & 4 deletions apps/web/utils/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { cookies } from "next/headers";
import { createReferral } from "@/utils/referral/referral-code";
import { captureException } from "@/utils/error";
import { handleReferralOnSignUp } from "@/utils/auth";
import { handleReferralOnSignUp, saveTokens } from "@/utils/auth";
import prisma from "@/utils/__mocks__/prisma";
import { clearUserErrorMessages } from "@/utils/error-messages";

// Mock the dependencies
vi.mock("server-only", () => ({}));
vi.mock("@/utils/prisma");
vi.mock("@/utils/error-messages", () => ({
addUserErrorMessage: vi.fn().mockResolvedValue(undefined),
clearUserErrorMessages: vi.fn().mockResolvedValue(undefined),
ErrorType: {
ACCOUNT_DISCONNECTED: "Account disconnected",
},
}));
vi.mock("@googleapis/people", () => ({
people: vi.fn(),
}));
vi.mock("@googleapis/gmail", () => ({
auth: {
OAuth2: vi.fn(),
},
}));
vi.mock("@/utils/encryption", () => ({
encryptToken: vi.fn((t) => t),
}));

vi.mock("next/headers", () => ({
cookies: vi.fn(),
Expand All @@ -19,8 +39,6 @@ vi.mock("@/utils/error", () => ({
captureException: vi.fn(),
}));

// Import the real function from auth.ts for testing

describe("handleReferralOnSignUp", () => {
const mockCookies = vi.mocked(cookies);
const mockCreateReferral = vi.mocked(createReferral);
Expand Down Expand Up @@ -94,3 +112,68 @@ describe("handleReferralOnSignUp", () => {
expect(mockCreateReferral).not.toHaveBeenCalled();
});
});

describe("saveTokens", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("clears disconnectedAt and error messages when saving tokens via emailAccountId", async () => {
prisma.emailAccount.update.mockResolvedValue({ userId: "user_1" } as any);

await saveTokens({
emailAccountId: "ea_1",
tokens: {
access_token: "new-access",
refresh_token: "new-refresh",
expires_at: 123_456_789,
},
accountRefreshToken: null,
provider: "google",
});

expect(prisma.emailAccount.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ea_1" },
data: expect.objectContaining({
account: {
update: expect.objectContaining({
disconnectedAt: null,
}),
},
}),
}),
);
expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" });
});

it("clears disconnectedAt and error messages when saving tokens via providerAccountId", async () => {
prisma.account.update.mockResolvedValue({ userId: "user_1" } as any);

await saveTokens({
providerAccountId: "pa_1",
tokens: {
access_token: "new-access",
refresh_token: "new-refresh",
expires_at: 123_456_789,
},
accountRefreshToken: null,
provider: "google",
});

expect(prisma.account.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
provider_providerAccountId: {
provider: "google",
providerAccountId: "pa_1",
},
}),
data: expect.objectContaining({
disconnectedAt: null,
}),
}),
);
expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" });
});
});
37 changes: 27 additions & 10 deletions apps/web/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
claimPendingPremiumInvite,
updateAccountSeats,
} from "@/utils/premium/server";
import { clearUserErrorMessages } from "@/utils/error-messages";
import prisma from "@/utils/prisma";

const logger = createScopedLogger("auth");
Expand Down Expand Up @@ -402,14 +403,22 @@ async function handleLinkAccount(account: Account) {
image: primaryPhotoUrl,
};

await prisma.emailAccount.upsert({
where: { email: profileData?.email },
update: data,
create: {
...data,
email: primaryEmail,
},
});
await prisma.$transaction([
prisma.emailAccount.upsert({
where: { email: profileData?.email },
update: data,
create: {
...data,
email: primaryEmail,
},
}),
prisma.account.update({
where: { id: account.id },
data: { disconnectedAt: null },
}),
]);

await clearUserErrorMessages({ userId: account.userId });
Comment thread
elie222 marked this conversation as resolved.
Comment thread
elie222 marked this conversation as resolved.

// Handle premium account seats
await updateAccountSeats({ userId: account.userId }).catch((error) => {
Expand Down Expand Up @@ -475,6 +484,7 @@ export async function saveTokens({
access_token: tokens.access_token,
expires_at: tokens.expires_at ? new Date(tokens.expires_at * 1000) : null,
refresh_token: refreshToken,
disconnectedAt: null,
};

if (emailAccountId) {
Expand All @@ -486,10 +496,13 @@ export async function saveTokens({
if (data.refresh_token)
data.refresh_token = encryptToken(data.refresh_token) || "";

await prisma.emailAccount.update({
const emailAccount = await prisma.emailAccount.update({
where: { id: emailAccountId },
data: { account: { update: data } },
select: { userId: true },
});

await clearUserErrorMessages({ userId: emailAccount.userId });
} else {
if (!providerAccountId) {
logger.error("No providerAccountId found in database", {
Expand All @@ -501,7 +514,7 @@ export async function saveTokens({
return;
}

return await prisma.account.update({
const account = await prisma.account.update({
where: {
provider_providerAccountId: {
provider,
Expand All @@ -510,6 +523,10 @@ export async function saveTokens({
},
data,
});

await clearUserErrorMessages({ userId: account.userId });

return account;
}
}

Expand Down
116 changes: 116 additions & 0 deletions apps/web/utils/auth/cleanup-invalid-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import prisma from "@/utils/__mocks__/prisma";
import { cleanupInvalidTokens } from "./cleanup-invalid-tokens";
import { sendReconnectionEmail } from "@inboxzero/resend";
import { createScopedLogger } from "@/utils/logger";
import { addUserErrorMessage } from "@/utils/error-messages";

vi.mock("@/utils/prisma");
vi.mock("@inboxzero/resend", () => ({
sendReconnectionEmail: vi.fn(),
}));
vi.mock("@/utils/error-messages", () => ({
addUserErrorMessage: vi.fn().mockResolvedValue(undefined),
ErrorType: {
ACCOUNT_DISCONNECTED: "Account disconnected",
},
}));
vi.mock("@/utils/unsubscribe", () => ({
createUnsubscribeToken: vi.fn().mockResolvedValue("mock-token"),
}));

describe("cleanupInvalidTokens", () => {
const logger = createScopedLogger("test");

beforeEach(() => {
vi.clearAllMocks();
});

const mockEmailAccount = {
id: "ea_1",
email: "test@example.com",
accountId: "acc_1",
userId: "user_1",
account: { disconnectedAt: null },
watchEmailsExpirationDate: new Date(Date.now() + 1000 * 60 * 60), // Valid expiration
};

it("marks account as disconnected and sends email on invalid_grant when account is watched", async () => {
prisma.emailAccount.findUnique.mockResolvedValue(mockEmailAccount as any);
prisma.account.updateMany.mockResolvedValue({ count: 1 });

await cleanupInvalidTokens({
emailAccountId: "ea_1",
reason: "invalid_grant",
logger,
});

expect(prisma.account.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "acc_1", disconnectedAt: null },
data: expect.objectContaining({
disconnectedAt: expect.any(Date),
}),
}),
);
expect(sendReconnectionEmail).toHaveBeenCalled();
expect(addUserErrorMessage).toHaveBeenCalledWith(
"user_1",
"Account disconnected",
expect.stringContaining("test@example.com"),
);
});

it("marks as disconnected but skips email if account is not watched", async () => {
prisma.emailAccount.findUnique.mockResolvedValue({
...mockEmailAccount,
watchEmailsExpirationDate: null,
} as any);
prisma.account.updateMany.mockResolvedValue({ count: 1 });

await cleanupInvalidTokens({
emailAccountId: "ea_1",
reason: "invalid_grant",
logger,
});

expect(prisma.account.updateMany).toHaveBeenCalled();
expect(sendReconnectionEmail).not.toHaveBeenCalled();
expect(addUserErrorMessage).toHaveBeenCalledWith(
"user_1",
"Account disconnected",
expect.stringContaining("test@example.com"),
);
});

it("returns early if account is already disconnected", async () => {
prisma.emailAccount.findUnique.mockResolvedValue({
...mockEmailAccount,
account: { disconnectedAt: new Date() },
} as any);

await cleanupInvalidTokens({
emailAccountId: "ea_1",
reason: "invalid_grant",
logger,
});

expect(prisma.account.updateMany).not.toHaveBeenCalled();
expect(sendReconnectionEmail).not.toHaveBeenCalled();
});

it("does not send email for insufficient_permissions", async () => {
prisma.emailAccount.findUnique.mockResolvedValue(mockEmailAccount as any);
prisma.account.updateMany.mockResolvedValue({ count: 1 });

await cleanupInvalidTokens({
emailAccountId: "ea_1",
reason: "insufficient_permissions",
logger,
});

expect(prisma.account.updateMany).toHaveBeenCalled();
expect(sendReconnectionEmail).not.toHaveBeenCalled();
expect(addUserErrorMessage).not.toHaveBeenCalled();
});
});
Loading
Loading