diff --git a/docs/self-hosting/advanced/auth/providers/casdoor.mdx b/docs/self-hosting/advanced/auth/providers/casdoor.mdx index 916252b9aa..73955d699c 100644 --- a/docs/self-hosting/advanced/auth/providers/casdoor.mdx +++ b/docs/self-hosting/advanced/auth/providers/casdoor.mdx @@ -56,7 +56,7 @@ tags: > Available in Casdoor `>=1.843.0`. - Configure Casdoor Webhook to sync user data updates to LobeChat. + Configure Casdoor [Webhook](https://www.casdoor.org/docs/webhooks/overview#setting-up-a-webhook) to sync user data updates to LobeChat. 1. Go to **Admin Tools** -> **Webhooks** and create a Webhook 2. Fill in the following fields: diff --git a/docs/self-hosting/advanced/auth/providers/casdoor.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/casdoor.zh-CN.mdx index 0a9908c976..5946cce9f9 100644 --- a/docs/self-hosting/advanced/auth/providers/casdoor.zh-CN.mdx +++ b/docs/self-hosting/advanced/auth/providers/casdoor.zh-CN.mdx @@ -54,7 +54,7 @@ tags: > 在 Casdoor `>=1.843.0` 上可用。 - 配置 Casdoor 的 Webhook 以便在用户信息更新时同步到 LobeChat。 + 配置 Casdoor 的 [Webhook](https://www.casdoor.org/docs/webhooks/overview#setting-up-a-webhook) 以便在用户信息更新时同步到 LobeChat。 1. 前往 `管理工具` -> `Webhooks`,创建一个 Webhook 2. 填写以下字段: diff --git a/docs/self-hosting/advanced/auth/providers/logto.mdx b/docs/self-hosting/advanced/auth/providers/logto.mdx index ae68f992e0..a125e9df59 100644 --- a/docs/self-hosting/advanced/auth/providers/logto.mdx +++ b/docs/self-hosting/advanced/auth/providers/logto.mdx @@ -56,7 +56,7 @@ tags: ### Configure Webhook (Optional) - Configure Logto Webhook to sync user data updates to LobeChat. + Configure Logto [Webhook](https://docs.logto.io/developers/webhooks/configure-webhooks) to sync user data updates to LobeChat. 1. Go to **Webhooks** in Logto Console and create a Webhook 2. Fill in the following fields: diff --git a/docs/self-hosting/advanced/auth/providers/logto.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/logto.zh-CN.mdx index 05a4030070..1f351c6af6 100644 --- a/docs/self-hosting/advanced/auth/providers/logto.zh-CN.mdx +++ b/docs/self-hosting/advanced/auth/providers/logto.zh-CN.mdx @@ -54,7 +54,7 @@ tags: ### 配置 Webhook(可选) - 配置 Logto 的 Webhook,以便在用户信息更新时 LobeChat 可以接收到通知并同步数据。 + 配置 Logto 的 [Webhook](https://docs.logto.io/developers/webhooks/configure-webhooks),以便在用户信息更新时 LobeChat 可以接收到通知并同步数据。 1. 前往 Logto 控制台的 `Webhooks`,创建一个 Webhook 2. 填写以下字段: diff --git a/package.json b/package.json index 61c59e5b08..c9b08d4b62 100644 --- a/package.json +++ b/package.json @@ -299,7 +299,6 @@ "pdfjs-dist": "5.4.530", "pdfkit": "^0.17.2", "pg": "^8.17.2", - "pino": "^10.3.0", "plaiceholder": "^3.0.0", "polished": "^4.3.1", "posthog-js": "~1.278.0", diff --git a/scripts/_shared/checkDeprecatedAuth.js b/scripts/_shared/checkDeprecatedAuth.js index fdd91082dc..bcb0d73b9e 100644 --- a/scripts/_shared/checkDeprecatedAuth.js +++ b/scripts/_shared/checkDeprecatedAuth.js @@ -15,6 +15,7 @@ const MIGRATION_DOC_BASE = 'https://lobehub.com/docs/self-hosting/advanced/auth' * message: string; * docUrl?: string; * formatVar?: (envVar: string) => string; + * severity?: 'error' | 'warning'; * }>} */ const DEPRECATED_CHECKS = [ @@ -144,8 +145,9 @@ const DEPRECATED_CHECKS = [ return []; }, message: - 'Casdoor webhook is required for syncing user data (email, avatar, etc.) to LobeChat. Without it, users without email configured in Casdoor cannot login. Please configure CASDOOR_WEBHOOK_SECRET following the documentation.', + 'Casdoor webhook is recommended for syncing user data (email, avatar, etc.) to LobeChat. This is especially important for users migrating from NextAuth to Better Auth - users without email configured in Casdoor will not be able to login. Consider configuring CASDOOR_WEBHOOK_SECRET following the documentation.', name: 'Casdoor Webhook', + severity: 'warning', }, { docUrl: `${MIGRATION_DOC_BASE}/providers/logto`, @@ -157,8 +159,9 @@ const DEPRECATED_CHECKS = [ return []; }, message: - 'Logto webhook is required for syncing user data (email, avatar, etc.) to LobeChat. Without it, users without email configured in Logto cannot login. Please configure LOGTO_WEBHOOK_SIGNING_KEY following the documentation.', + 'Logto webhook is recommended for syncing user data (email, avatar, etc.) to LobeChat. This is especially important for users migrating from NextAuth to Better Auth - users without email configured in Logto will not be able to login. Consider configuring LOGTO_WEBHOOK_SIGNING_KEY following the documentation.', name: 'Logto Webhook', + severity: 'warning', }, { docUrl: `${MIGRATION_DOC_BASE}/nextauth-to-betterauth`, @@ -185,18 +188,22 @@ const DEPRECATED_CHECKS = [ ]; /** - * Print a single deprecation error block + * Print a single deprecation block (error or warning) */ -function printErrorBlock(name, vars, message, docUrl, formatVar) { - console.error(`\n❌ ${name}`); - console.error('─'.repeat(50)); - console.error('Detected deprecated environment variables:'); +function printIssueBlock(name, vars, message, docUrl, formatVar, severity = 'error') { + const isWarning = severity === 'warning'; + const icon = isWarning ? '⚠️' : '❌'; + const log = isWarning ? console.warn : console.error; + + log(`\n${icon} ${name}`); + log('─'.repeat(50)); + log(isWarning ? 'Missing recommended environment variables:' : 'Detected deprecated environment variables:'); for (const envVar of vars) { - console.error(` • ${formatVar ? formatVar(envVar) : envVar}`); + log(` • ${formatVar ? formatVar(envVar) : envVar}`); } - console.error(`\n${message}`); + log(`\n${message}`); if (docUrl) { - console.error(`📖 Migration guide: ${docUrl}`); + log(`📖 Documentation: ${docUrl}`); } } @@ -208,23 +215,46 @@ function printErrorBlock(name, vars, message, docUrl, formatVar) { function checkDeprecatedAuth(options = {}) { const { action = 'redeploy' } = options; - const foundIssues = []; + const errors = []; + const warnings = []; + for (const check of DEPRECATED_CHECKS) { const foundVars = check.getVars(); if (foundVars.length > 0) { - foundIssues.push({ ...check, foundVars }); + const issue = { ...check, foundVars }; + if (check.severity === 'warning') { + warnings.push(issue); + } else { + errors.push(issue); + } } } - if (foundIssues.length > 0) { + // Print warnings (non-blocking) + if (warnings.length > 0) { + console.warn('\n' + '═'.repeat(70)); + console.warn(`⚠️ WARNING: Found ${warnings.length} recommended configuration(s) missing`); + console.warn('═'.repeat(70)); + + for (const issue of warnings) { + printIssueBlock(issue.name, issue.foundVars, issue.message, issue.docUrl, issue.formatVar, 'warning'); + } + + console.warn('\n' + '═'.repeat(70)); + console.warn('These are recommendations. Your application will still run.'); + console.warn('═'.repeat(70) + '\n'); + } + + // Print errors and exit (blocking) + if (errors.length > 0) { console.error('\n' + '═'.repeat(70)); console.error( - `❌ ERROR: Found ${foundIssues.length} deprecated environment variable issue(s)!`, + `❌ ERROR: Found ${errors.length} deprecated environment variable issue(s)!`, ); console.error('═'.repeat(70)); - for (const issue of foundIssues) { - printErrorBlock(issue.name, issue.foundVars, issue.message, issue.docUrl, issue.formatVar); + for (const issue of errors) { + printIssueBlock(issue.name, issue.foundVars, issue.message, issue.docUrl, issue.formatVar, 'error'); } console.error('\n' + '═'.repeat(70)); diff --git a/scripts/_shared/checkDeprecatedAuth.test.ts b/scripts/_shared/checkDeprecatedAuth.test.ts new file mode 100644 index 0000000000..55f89091cc --- /dev/null +++ b/scripts/_shared/checkDeprecatedAuth.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Store original env +const originalEnv = { ...process.env }; + +// Mock process.exit to prevent actual exit +const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + +// Mock console methods +const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); +const mockConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + +describe('checkDeprecatedAuth', () => { + beforeEach(() => { + // Reset env before each test + process.env = { ...originalEnv }; + vi.clearAllMocks(); + // Clear module cache to ensure fresh import + vi.resetModules(); + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + }); + + it('should not exit when no deprecated env vars are set', async () => { + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should exit with code 1 when NextAuth env vars are detected', async () => { + process.env.NEXT_AUTH_SECRET = 'test-secret'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalled(); + }); + + it('should exit with code 1 when NEXTAUTH env vars are detected', async () => { + process.env.NEXTAUTH_SECRET = 'test-secret'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit with code 1 when Clerk env vars are detected', async () => { + process.env.CLERK_SECRET_KEY = 'test-key'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit with code 1 when ACCESS_CODE is set', async () => { + process.env.ACCESS_CODE = 'test-code'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit with code 1 when APP_URL has trailing slash', async () => { + process.env.APP_URL = 'https://example.com/'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should not exit when APP_URL has no trailing slash', async () => { + process.env.APP_URL = 'https://example.com'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + }); + + describe('webhook warnings (non-blocking)', () => { + it('should warn but not exit when Casdoor webhook is missing', async () => { + process.env.AUTH_SSO_PROVIDERS = 'casdoor'; + // CASDOOR_WEBHOOK_SECRET is not set + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockConsoleWarn).toHaveBeenCalled(); + }); + + it('should warn but not exit when Logto webhook is missing', async () => { + process.env.AUTH_SSO_PROVIDERS = 'logto'; + // LOGTO_WEBHOOK_SIGNING_KEY is not set + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockConsoleWarn).toHaveBeenCalled(); + }); + + it('should not warn when Casdoor webhook is configured', async () => { + process.env.AUTH_SSO_PROVIDERS = 'casdoor'; + process.env.CASDOOR_WEBHOOK_SECRET = 'test-secret'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('should not warn when Logto webhook is configured', async () => { + process.env.AUTH_SSO_PROVIDERS = 'logto'; + process.env.LOGTO_WEBHOOK_SIGNING_KEY = 'test-key'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('should not warn when provider is not casdoor or logto', async () => { + process.env.AUTH_SSO_PROVIDERS = 'google'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + }); + + describe('mixed errors and warnings', () => { + it('should exit when there are errors even if there are also warnings', async () => { + process.env.AUTH_SSO_PROVIDERS = 'logto'; // warning + process.env.ACCESS_CODE = 'test-code'; // error + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConsoleWarn).toHaveBeenCalled(); + expect(mockConsoleError).toHaveBeenCalled(); + }); + }); + + describe('action parameter', () => { + it('should use "redeploy" as default action', async () => { + process.env.ACCESS_CODE = 'test-code'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth(); + + const calls = mockConsoleError.mock.calls.flat().join(' '); + expect(calls).toContain('redeploy'); + }); + + it('should use custom action when provided', async () => { + process.env.ACCESS_CODE = 'test-code'; + + const { checkDeprecatedAuth } = await import('./checkDeprecatedAuth.js'); + checkDeprecatedAuth({ action: 'restart' }); + + const calls = mockConsoleError.mock.calls.flat().join(' '); + expect(calls).toContain('restart'); + }); + }); +}); diff --git a/src/app/(backend)/api/webhooks/casdoor/route.ts b/src/app/(backend)/api/webhooks/casdoor/route.ts index d83c0d38db..9d43e7d9b5 100644 --- a/src/app/(backend)/api/webhooks/casdoor/route.ts +++ b/src/app/(backend)/api/webhooks/casdoor/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'; import { serverDB } from '@/database/server'; import { authEnv } from '@/envs/auth'; -import { pino } from '@/libs/logger'; import { WebhookUserService } from '@/server/services/webhookUser'; import { validateRequest } from './validateRequest'; @@ -36,7 +35,7 @@ export const POST = async (req: Request): Promise => { } default: { - pino.warn( + console.warn( `${req.url} received event type "${action}", but no handler is defined for this type`, ); return NextResponse.json({ error: `unrecognised payload type: ${action}` }, { status: 400 }); diff --git a/src/app/(backend)/api/webhooks/logto/route.ts b/src/app/(backend)/api/webhooks/logto/route.ts index 2d279203a1..cf038ece48 100644 --- a/src/app/(backend)/api/webhooks/logto/route.ts +++ b/src/app/(backend)/api/webhooks/logto/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'; import { serverDB } from '@/database/server'; import { authEnv } from '@/envs/auth'; -import { pino } from '@/libs/logger'; import { WebhookUserService } from '@/server/services/webhookUser'; import { validateRequest } from './validateRequest'; @@ -19,7 +18,7 @@ export const POST = async (req: Request): Promise => { const { event, data } = payload; - pino.trace(`logto webhook payload: ${{ data, event }}`); + console.log(`logto webhook payload: ${{ data, event }}`); const webhookUserService = new WebhookUserService(serverDB); switch (event) { @@ -47,7 +46,7 @@ export const POST = async (req: Request): Promise => { } default: { - pino.warn( + console.warn( `${req.url} received event type "${event}", but no handler is defined for this type`, ); return NextResponse.json({ error: `unrecognised payload type: ${event}` }, { status: 400 }); diff --git a/src/app/(backend)/trpc/async/[trpc]/route.ts b/src/app/(backend)/trpc/async/[trpc]/route.ts index b6fc66c02c..6047831503 100644 --- a/src/app/(backend)/trpc/async/[trpc]/route.ts +++ b/src/app/(backend)/trpc/async/[trpc]/route.ts @@ -1,7 +1,6 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import type { NextRequest } from 'next/server'; -import { pino } from '@/libs/logger'; import { createAsyncRouteContext } from '@/libs/trpc/async/context'; import { prepareRequestForTRPC } from '@/libs/trpc/utils/request-adapter'; import { createResponseMeta } from '@/libs/trpc/utils/responseMeta'; @@ -25,7 +24,7 @@ const handler = (req: NextRequest) => { endpoint: '/trpc/async', onError: ({ error, path, type }) => { - pino.info(`Error in tRPC handler (async) on path: ${path}, type: ${type}`); + console.log(`Error in tRPC handler (async) on path: ${path}, type: ${type}`); console.error(error); }, diff --git a/src/app/(backend)/trpc/mobile/[trpc]/route.ts b/src/app/(backend)/trpc/mobile/[trpc]/route.ts index b82eb43c1e..c4a152da8e 100644 --- a/src/app/(backend)/trpc/mobile/[trpc]/route.ts +++ b/src/app/(backend)/trpc/mobile/[trpc]/route.ts @@ -1,7 +1,6 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import type { NextRequest } from 'next/server'; -import { pino } from '@/libs/logger'; import { createLambdaContext } from '@/libs/trpc/lambda/context'; import { prepareRequestForTRPC } from '@/libs/trpc/utils/request-adapter'; import { createResponseMeta } from '@/libs/trpc/utils/responseMeta'; @@ -21,7 +20,7 @@ const handler = (req: NextRequest) => { endpoint: '/trpc/mobile', onError: ({ error, path, type }) => { - pino.info(`Error in tRPC handler (mobile) on path: ${path}, type: ${type}`); + console.log(`Error in tRPC handler (mobile) on path: ${path}, type: ${type}`); console.error(error); }, diff --git a/src/libs/logger/index.ts b/src/libs/logger/index.ts deleted file mode 100644 index 6bf5148d6c..0000000000 --- a/src/libs/logger/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Pino from 'pino'; - -export const pino = Pino({ - level: process.env.LOG_LEVEL ? process.env.LOG_LEVEL : 'info', -}); diff --git a/src/libs/next/config/define-config.ts b/src/libs/next/config/define-config.ts index 29c88a941c..a0a7cdd95e 100644 --- a/src/libs/next/config/define-config.ts +++ b/src/libs/next/config/define-config.ts @@ -367,9 +367,6 @@ export function defineConfig(config: CustomNextConfig) { type: 'javascript/auto', }); - // https://github.com/pinojs/pino/issues/688#issuecomment-637763276 - baseWebpackConfig.externals.push('pino-pretty'); - baseWebpackConfig.resolve.alias.canvas = false; // to ignore epub2 compile error diff --git a/src/server/routers/lambda/agent.ts b/src/server/routers/lambda/agent.ts index 23f0b3ac23..3fd07a1129 100644 --- a/src/server/routers/lambda/agent.ts +++ b/src/server/routers/lambda/agent.ts @@ -9,7 +9,6 @@ import { KnowledgeBaseModel } from '@/database/models/knowledgeBase'; import { SessionModel } from '@/database/models/session'; import { UserModel } from '@/database/models/user'; import { insertAgentSchema } from '@/database/schemas'; -import { pino } from '@/libs/logger'; import { authedProcedure, router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; import { AgentService } from '@/server/services/agent'; @@ -213,7 +212,7 @@ export const agentRouter = router({ if (!user) return DEFAULT_AGENT_CONFIG; const res = await ctx.agentService.createInbox(); - pino.info({ res }, 'create inbox session'); + console.log('create inbox session', res); } } diff --git a/src/server/services/user/index.ts b/src/server/services/user/index.ts index 0a35094d89..bbbfa1738b 100644 --- a/src/server/services/user/index.ts +++ b/src/server/services/user/index.ts @@ -4,7 +4,6 @@ import { type LobeChatDatabase } from '@lobechat/database'; import { initNewUserForBusiness } from '@/business/server/user'; import { UserModel } from '@/database/models/user'; import { initializeServerAnalytics } from '@/libs/analytics'; -import { pino } from '@/libs/logger'; import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; import { FileS3 } from '@/server/modules/S3'; @@ -67,7 +66,7 @@ export class UserService { } return Buffer.from(file); } catch (error) { - pino.error({ error }, 'Failed to get user avatar'); + console.error('Failed to get user avatar', error); } }; } diff --git a/src/server/services/webhookUser/index.test.ts b/src/server/services/webhookUser/index.test.ts new file mode 100644 index 0000000000..53f3e1befa --- /dev/null +++ b/src/server/services/webhookUser/index.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { UserModel } from '@/database/models/user'; + +import { WebhookUserService } from './index'; + +vi.mock('@/database/models/user', () => ({ + UserModel: vi.fn(), +})); + +describe('WebhookUserService', () => { + let service: WebhookUserService; + let mockDb: any; + let mockUserModel: any; + + const mockUser = { + avatar: 'https://example.com/avatar.png', + email: 'test@example.com', + fullName: 'Test User', + id: 'user-123', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockUserModel = { + updateUser: vi.fn(), + }; + (UserModel as any).mockImplementation(() => mockUserModel); + + const deleteChainMock = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockDb = { + delete: vi.fn().mockReturnValue(deleteChainMock), + query: { + account: { + findFirst: vi.fn(), + }, + users: { + findFirst: vi.fn(), + }, + }, + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn(), + }; + + service = new WebhookUserService(mockDb); + }); + + describe('safeUpdateUser', () => { + it('should update user when found in Better Auth accounts table', async () => { + const betterAuthAccount = { userId: 'user-123', providerId: 'logto', accountId: 'acc-123' }; + + mockDb.query.account.findFirst.mockResolvedValue(betterAuthAccount); + mockDb.query.users.findFirst.mockResolvedValue(mockUser); + + const updateData = { + avatar: 'https://new-avatar.com/img.png', + email: 'new@example.com', + fullName: 'New Name', + }; + + const result = await service.safeUpdateUser( + { providerId: 'logto', accountId: 'acc-123' }, + updateData, + ); + + expect(UserModel).toHaveBeenCalledWith(mockDb, 'user-123'); + expect(mockUserModel.updateUser).toHaveBeenCalledWith({ + avatar: updateData.avatar, + email: updateData.email, + fullName: updateData.fullName, + }); + expect(result.status).toBe(200); + }); + + it('should update user when found in NextAuth accounts table (fallback)', async () => { + // Better Auth account not found + mockDb.query.account.findFirst.mockResolvedValue(null); + + // Setup chain mock for NextAuth query + const chainMock = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb) => cb([{ users: mockUser }])), + where: vi.fn().mockReturnThis(), + }; + mockDb.select.mockReturnValue(chainMock); + + const updateData = { email: 'updated@example.com' }; + + const result = await service.safeUpdateUser( + { providerId: 'casdoor', accountId: 'casdoor-acc-456' }, + updateData, + ); + + expect(UserModel).toHaveBeenCalledWith(mockDb, 'user-123'); + expect(mockUserModel.updateUser).toHaveBeenCalledWith({ + avatar: undefined, + email: 'updated@example.com', + fullName: undefined, + }); + expect(result.status).toBe(200); + }); + + it('should warn and not update when user not found', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Better Auth account not found + mockDb.query.account.findFirst.mockResolvedValue(null); + + // NextAuth account also not found + const chainMock = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb) => cb([])), + where: vi.fn().mockReturnThis(), + }; + mockDb.select.mockReturnValue(chainMock); + + const result = await service.safeUpdateUser( + { providerId: 'logto', accountId: 'unknown-acc' }, + { email: 'test@example.com' }, + ); + + expect(UserModel).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(result.status).toBe(200); + + consoleWarnSpy.mockRestore(); + }); + + it('should only update provided fields', async () => { + const betterAuthAccount = { userId: 'user-123', providerId: 'logto', accountId: 'acc-123' }; + + mockDb.query.account.findFirst.mockResolvedValue(betterAuthAccount); + mockDb.query.users.findFirst.mockResolvedValue(mockUser); + + // Only updating email + const result = await service.safeUpdateUser( + { providerId: 'logto', accountId: 'acc-123' }, + { email: 'only-email@example.com' }, + ); + + expect(mockUserModel.updateUser).toHaveBeenCalledWith({ + avatar: undefined, + email: 'only-email@example.com', + fullName: undefined, + }); + expect(result.status).toBe(200); + }); + }); + + describe('safeSignOutUser', () => { + it('should delete all sessions when user found in Better Auth accounts table', async () => { + const betterAuthAccount = { userId: 'user-123', providerId: 'logto', accountId: 'acc-123' }; + + mockDb.query.account.findFirst.mockResolvedValue(betterAuthAccount); + mockDb.query.users.findFirst.mockResolvedValue(mockUser); + + const result = await service.safeSignOutUser({ + providerId: 'logto', + accountId: 'acc-123', + }); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(result.status).toBe(200); + }); + + it('should delete all sessions when user found in NextAuth accounts table (fallback)', async () => { + // Better Auth account not found + mockDb.query.account.findFirst.mockResolvedValue(null); + + // NextAuth account found + const chainMock = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb) => cb([{ users: mockUser }])), + where: vi.fn().mockReturnThis(), + }; + mockDb.select.mockReturnValue(chainMock); + + const result = await service.safeSignOutUser({ + providerId: 'casdoor', + accountId: 'casdoor-acc-456', + }); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(result.status).toBe(200); + }); + + it('should warn and not delete sessions when user not found', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Better Auth account not found + mockDb.query.account.findFirst.mockResolvedValue(null); + + // NextAuth account also not found + const chainMock = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb) => cb([])), + where: vi.fn().mockReturnThis(), + }; + mockDb.select.mockReturnValue(chainMock); + + const result = await service.safeSignOutUser({ + providerId: 'logto', + accountId: 'unknown-acc', + }); + + expect(mockDb.delete).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(result.status).toBe(200); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('getUserByAccount (via public methods)', () => { + it('should prioritize Better Auth account over NextAuth account', async () => { + const betterAuthAccount = { userId: 'better-auth-user', providerId: 'logto', accountId: 'acc' }; + const betterAuthUser = { ...mockUser, id: 'better-auth-user' }; + + mockDb.query.account.findFirst.mockResolvedValue(betterAuthAccount); + mockDb.query.users.findFirst.mockResolvedValue(betterAuthUser); + + await service.safeUpdateUser({ providerId: 'logto', accountId: 'acc' }, {}); + + // Should use Better Auth user, not query NextAuth table + expect(UserModel).toHaveBeenCalledWith(mockDb, 'better-auth-user'); + expect(mockDb.select).not.toHaveBeenCalled(); + }); + + it('should fallback to NextAuth account when Better Auth account not found', async () => { + const nextAuthUser = { ...mockUser, id: 'nextauth-user' }; + + // Better Auth account not found + mockDb.query.account.findFirst.mockResolvedValue(null); + + // NextAuth account found + const chainMock = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb) => cb([{ users: nextAuthUser }])), + where: vi.fn().mockReturnThis(), + }; + mockDb.select.mockReturnValue(chainMock); + + await service.safeUpdateUser({ providerId: 'casdoor', accountId: 'acc' }, {}); + + // Should use NextAuth user + expect(UserModel).toHaveBeenCalledWith(mockDb, 'nextauth-user'); + }); + + it('should return null when user not found in both tables', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Better Auth account not found + mockDb.query.account.findFirst.mockResolvedValue(null); + + // NextAuth account also not found + const chainMock = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb) => cb([])), + where: vi.fn().mockReturnThis(), + }; + mockDb.select.mockReturnValue(chainMock); + + await service.safeUpdateUser({ providerId: 'unknown', accountId: 'unknown' }, {}); + + // Should not create UserModel since user not found + expect(UserModel).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/src/server/services/webhookUser/index.ts b/src/server/services/webhookUser/index.ts index 42202a1b22..7d1ea3ee8d 100644 --- a/src/server/services/webhookUser/index.ts +++ b/src/server/services/webhookUser/index.ts @@ -3,8 +3,7 @@ import { and, eq } from 'drizzle-orm'; import { NextResponse } from 'next/server'; import { UserModel } from '@/database/models/user'; -import { type UserItem, account, session } from '@/database/schemas'; -import { pino } from '@/libs/logger'; +import { type UserItem, account, nextauthAccounts, session, users } from '@/database/schemas'; export class WebhookUserService { private db: LobeChatDatabase; @@ -14,7 +13,9 @@ export class WebhookUserService { } /** - * Find user by provider account info + * Find user by provider account info. + * First checks Better Auth accounts table, then falls back to NextAuth accounts table + * for users who performed simple migration (without migrating accounts data). */ private getUserByAccount = async ({ providerId, @@ -23,15 +24,31 @@ export class WebhookUserService { accountId: string; providerId: string; }) => { - const result = await this.db.query.account.findFirst({ + // First, try Better Auth accounts table + const betterAuthAccount = await this.db.query.account.findFirst({ where: and(eq(account.providerId, providerId), eq(account.accountId, accountId)), }); - if (!result) return null; + if (betterAuthAccount) { + return this.db.query.users.findFirst({ + where: eq(users.id, betterAuthAccount.userId), + }); + } - return this.db.query.users.findFirst({ - where: eq(account.userId, result.userId), - }); + // Fallback to NextAuth accounts table for simple migration users + const nextAuthAccount = await this.db + .select({ users }) + .from(nextauthAccounts) + .innerJoin(users, eq(nextauthAccounts.userId, users.id)) + .where( + and( + eq(nextauthAccounts.provider, providerId), + eq(nextauthAccounts.providerAccountId, accountId), + ), + ) + .then((res) => res[0]); + + return nextAuthAccount?.users ?? null; }; /** @@ -41,7 +58,7 @@ export class WebhookUserService { { accountId, providerId }: { accountId: string; providerId: string }, data: Partial, ) => { - pino.info(`updating user "${JSON.stringify({ accountId, providerId })}" due to webhook`); + console.log(`updating user "${JSON.stringify({ accountId, providerId })}" due to webhook`); const user = await this.getUserByAccount({ accountId, providerId }); @@ -53,7 +70,7 @@ export class WebhookUserService { fullName: data?.fullName, }); } else { - pino.warn( + console.warn( `[${providerId}]: Webhook user "${JSON.stringify({ accountId, providerId })}" update for "${JSON.stringify(data)}", but no user was found.`, ); } @@ -71,14 +88,14 @@ export class WebhookUserService { accountId: string; providerId: string; }) => { - pino.info(`Signing out user "${JSON.stringify({ accountId, providerId })}"`); + console.log(`Signing out user "${JSON.stringify({ accountId, providerId })}"`); const user = await this.getUserByAccount({ accountId, providerId }); if (user?.id) { await this.db.delete(session).where(eq(session.userId, user.id)); } else { - pino.warn( + console.warn( `[${providerId}]: Webhook user "${JSON.stringify({ accountId, providerId })}" signout, but no user was found.`, ); }