mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix: webhook user service compatibility for old nextauth users (#11826)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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. 填写以下字段:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. 填写以下字段:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
180
scripts/_shared/checkDeprecatedAuth.test.ts
Normal file
180
scripts/_shared/checkDeprecatedAuth.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<NextResponse> => {
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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<NextResponse> => {
|
||||
|
||||
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<NextResponse> => {
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import Pino from 'pino';
|
||||
|
||||
export const pino = Pino({
|
||||
level: process.env.LOG_LEVEL ? process.env.LOG_LEVEL : 'info',
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
290
src/server/services/webhookUser/index.test.ts
Normal file
290
src/server/services/webhookUser/index.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<UserItem>,
|
||||
) => {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user