Commit 14ff938b by Archer Committed by GitHub

feat: bind homepage visitor id after authentication (#7319)

* feat: connect home attribution to CRM identity

* refactor: keep only visitor id attribution

* refactor: remove fastgpt source query parameter

* refactor: remove fastgpt sem source field

* refactor: report unified visitor contact

* fix: prefer stored visitor identity on login

* fix: validate visitor ids and decouple CRM reporting

* fix: validate visitor id before client storage

* fix: allow internal CRM endpoints
parent 0db2c260
......@@ -195,6 +195,9 @@ services:
AIPROXY_API_TOKEN: *x-aiproxy-token
# MCP Server 代理地址,用于 MCP 使用方式页拼接 SSE 地址
SSE_MCP_SERVER_PROXY_ENDPOINT:
# 官网访客归因 CRM;地址需包含 /api/v1,为空时不进行身份上报
CRM_API_URL:
CRM_API_KEY:
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -43,6 +43,8 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| `AIPROXY_API_TOKEN` | Empty | Token for calling AI Proxy. |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Default OpenAI-compatible model endpoint when AI Proxy is not configured. |
| `CHAT_API_KEY` | Empty | Default OpenAI-compatible model API key when AI Proxy token is not configured. |
| `CRM_API_URL` | Empty | Lead attribution CRM API base URL (including `/api/v1`). Empty disables identity reporting. |
| `CRM_API_KEY` | Empty | CRM admin API key used to bind a FastGPT user to `visitor_id` after registration or login. |
| `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | Plugin marketplace API URL. |
| `FEISHU_BASE_URL` | `https://open.feishu.cn` | Lark Open Platform URL. Use your private Lark domain when self-hosting Lark. |
| `DINGTALK_BASE_URL` | `https://api.dingtalk.com` | DingTalk new API base URL. |
......
......@@ -43,6 +43,8 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `AIPROXY_API_TOKEN` | 空 | 调用 AI Proxy 使用的认证 Token。 |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 未配置 AI Proxy 时,兼容 OpenAI 协议的默认模型接口地址。 |
| `CHAT_API_KEY` | 空 | 未配置 AI Proxy Token 时,兼容 OpenAI 协议的默认模型 API Key。 |
| `CRM_API_URL` | 空 | 官网访客归因 CRM 的 API 基础地址(包含 `/api/v1`);为空时不进行身份上报。 |
| `CRM_API_KEY` | 空 | CRM 管理 API Key,用于注册或登录成功后按 `visitor_id` 绑定 FastGPT 用户。 |
| `MARKETPLACE_URL` | `https://v2.marketplace.fastgpt.cn` | 插件市场接口地址。 |
| `FEISHU_BASE_URL` | `https://open.feishu.cn` | 飞书开放平台地址,私有化飞书可改为对应域名。 |
| `DINGTALK_BASE_URL` | `https://api.dingtalk.com` | 钉钉新版 API 基础地址。 |
......
......@@ -58,33 +58,31 @@ export const PreLoginResponseSchema = z
export type PreLoginResponseType = z.infer<typeof PreLoginResponseSchema>;
// ===== Login by password =====
export const LoginByPasswordBodySchema = z
.object({
username: z.string().meta({
example: 'admin',
description: '用户名'
}),
password: z.string().meta({
example: 'hashed_password',
description: '密码'
}),
code: z.string().meta({
example: '123456',
description: '预登录验证码'
}),
language: LanguageSchema.optional().default('zh-CN').meta({
example: 'zh-CN',
description: '用户语言偏好'
})
export const LoginByPasswordBodySchema = TrackRegisterParamsSchema.extend({
username: z.string().meta({
example: 'admin',
description: '用户名'
}),
password: z.string().meta({
example: 'hashed_password',
description: '密码'
}),
code: z.string().meta({
example: '123456',
description: '预登录验证码'
}),
language: LanguageSchema.optional().default('zh-CN').meta({
example: 'zh-CN',
description: '用户语言偏好'
})
.meta({
example: {
username: 'admin',
password: 'hashed_password',
code: '123456',
language: 'zh-CN'
}
});
}).meta({
example: {
username: 'admin',
password: 'hashed_password',
code: '123456',
language: 'zh-CN'
}
});
export type LoginByPasswordBodyType = z.infer<typeof LoginByPasswordBodySchema>;
/* ===== Wecom Login ===== */
......@@ -107,7 +105,7 @@ export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({
export type OauthLoginBodyType = z.infer<typeof OauthLoginBodySchema>;
// ===== Fast Login =====
export const FastLoginBodySchema = z.object({
export const FastLoginBodySchema = TrackRegisterParamsSchema.extend({
token: z.string().meta({ description: 'Token' }),
code: z.string().meta({ description: 'Code' }),
language: LanguageSchema.optional().meta({ description: '语言' })
......
......@@ -7,11 +7,13 @@ export const ShortUrlSchema = z.object({
});
export type ShortUrlParams = z.infer<typeof ShortUrlSchema>;
export const VisitorIdSchema = z.string().trim().min(1).max(64);
export const FastGPT_SEM_Schema = ShortUrlSchema.extend({
keyword: z.string().optional(),
search: z.string().optional(),
source: z.string().optional(),
sourceDomain: z.string().optional()
sourceDomain: z.string().optional(),
visitor_id: VisitorIdSchema.optional()
});
export type FastGPTSemType = z.infer<typeof FastGPT_SEM_Schema>;
......
import { describe, expect, it } from 'vitest';
import { VisitorIdSchema } from '@fastgpt/global/support/marketing/type';
describe('VisitorIdSchema', () => {
it('accepts and trims homepage visitor ids', () => {
expect(VisitorIdSchema.parse(' 550e8400-e29b-41d4-a716-446655440000 ')).toBe(
'550e8400-e29b-41d4-a716-446655440000'
);
expect(VisitorIdSchema.parse('fg_m1_test_123')).toBe('fg_m1_test_123');
expect(VisitorIdSchema.parse('visitor/with/path')).toBe('visitor/with/path');
});
it('rejects empty and oversized visitor ids', () => {
expect(VisitorIdSchema.safeParse(' ').success).toBe(false);
expect(VisitorIdSchema.safeParse('a'.repeat(65)).success).toBe(false);
});
});
......@@ -60,6 +60,10 @@ export const serviceEnv = createEnv({
PRO_URL: UrlSchema.optional(),
PRO_TOKEN: z.string().min(32, 'PRO_TOKEN must be at least 32 characters').optional(),
// 官网访客归因 CRM;未配置地址时不进行身份上报
CRM_API_URL: UrlSchema.optional(),
CRM_API_KEY: z.string().optional(),
// Agent sandbox proxy
AGENT_SANDBOX_PROXY_SECRET: z
.string()
......
import { axiosWithoutSSRF } from '../../common/api/axios';
import { getLogger, LogCategories } from '../../common/logger';
import { serviceEnv } from '../../env';
import { FastGPT_SEM_Schema } from '@fastgpt/global/support/marketing/type';
const logger = getLogger(LogCategories.MODULE.USER.ACCOUNT);
type ReportCRMVisitorIdentityProps = {
visitorId?: string;
userId: string;
username: string;
contact?: string;
};
const getContact = (username: string, contact?: string) => {
const candidates = [contact, username].map((value) => value?.trim()).filter(Boolean) as string[];
const email = candidates.find((value) => value.includes('@'));
if (email) return email;
return candidates.find((value) => /^\+?[\d\s()-]{6,20}$/.test(value));
};
export const resolveCRMVisitorId = ({
storedFastgptSem,
incomingVisitorId
}: {
storedFastgptSem?: unknown;
incomingVisitorId?: string;
}) => {
const parsedFastgptSem = FastGPT_SEM_Schema.safeParse(storedFastgptSem);
const fastgptSem = parsedFastgptSem.success ? parsedFastgptSem.data : {};
const storedVisitorId = fastgptSem.visitor_id?.trim();
const normalizedIncomingVisitorId = incomingVisitorId?.trim();
const shouldPersist = !storedVisitorId && !!normalizedIncomingVisitorId;
return {
visitorId: storedVisitorId || normalizedIncomingVisitorId,
shouldPersist,
fastgptSem: shouldPersist
? { ...fastgptSem, visitor_id: normalizedIncomingVisitorId }
: fastgptSem
};
};
/**
* 将官网匿名 visitor_id 与 FastGPT 用户绑定。
* 上报失败只记日志,不能影响注册或登录结果。
*/
export const reportCRMVisitorIdentity = async ({
visitorId: rawVisitorId,
userId,
username,
contact
}: ReportCRMVisitorIdentityProps): Promise<void> => {
const crmApiUrl = serviceEnv.CRM_API_URL?.replace(/\/$/, '');
const visitorId = rawVisitorId?.trim();
if (!crmApiUrl || !visitorId) return;
if (!serviceEnv.CRM_API_KEY) {
logger.warn('Skip CRM visitor identity report: CRM_API_KEY is not configured');
return;
}
const normalizedContact = getContact(username, contact);
try {
await axiosWithoutSSRF.patch(
`${crmApiUrl}/contacts/visitor/${encodeURIComponent(visitorId)}/identity`,
{
cloud_user_id: userId,
...(normalizedContact && { contact: normalizedContact })
},
{
headers: {
'X-API-Key': serviceEnv.CRM_API_KEY
},
timeout: 5000
}
);
} catch (error) {
logger.warn('CRM visitor identity report failed', {
error,
visitorId,
userId
});
}
};
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
patch: vi.fn(),
warn: vi.fn(),
serviceEnv: {
CRM_API_URL: undefined as string | undefined,
CRM_API_KEY: undefined as string | undefined
}
}));
vi.mock('@fastgpt/service/common/api/axios', () => ({
axiosWithoutSSRF: { patch: mocks.patch }
}));
vi.mock('@fastgpt/service/common/logger', () => ({
getLogger: () => ({ warn: mocks.warn }),
LogCategories: { MODULE: { USER: { ACCOUNT: ['user', 'account'] } } }
}));
vi.mock('@fastgpt/service/env', () => ({
serviceEnv: mocks.serviceEnv
}));
import {
reportCRMVisitorIdentity,
resolveCRMVisitorId
} from '@fastgpt/service/support/marketing/attribution';
describe('resolveCRMVisitorId', () => {
it('prefers the visitor id stored on the user', () => {
expect(
resolveCRMVisitorId({
storedFastgptSem: { visitor_id: 'stored-visitor' },
incomingVisitorId: 'incoming-visitor'
})
).toEqual({
visitorId: 'stored-visitor',
shouldPersist: false,
fastgptSem: { visitor_id: 'stored-visitor' }
});
});
it('uses and persists the incoming visitor id when the user has none', () => {
expect(
resolveCRMVisitorId({
storedFastgptSem: { keyword: 'FastGPT' },
incomingVisitorId: ' incoming-visitor '
})
).toEqual({
visitorId: 'incoming-visitor',
shouldPersist: true,
fastgptSem: { keyword: 'FastGPT', visitor_id: 'incoming-visitor' }
});
});
});
describe('reportCRMVisitorIdentity', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.serviceEnv.CRM_API_URL = undefined;
mocks.serviceEnv.CRM_API_KEY = undefined;
});
it('skips reporting when CRM_API_URL is not configured', async () => {
await reportCRMVisitorIdentity({
visitorId: 'visitor-1',
userId: 'user-1',
username: 'user@example.com'
});
expect(mocks.patch).not.toHaveBeenCalled();
});
it('skips reporting when visitor_id is missing', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
await reportCRMVisitorIdentity({
userId: 'user-1',
username: 'user@example.com'
});
expect(mocks.patch).not.toHaveBeenCalled();
});
it('reports the FastGPT identity using the visitor_id', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1/';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
await reportCRMVisitorIdentity({
visitorId: 'visitor/1',
userId: 'user-1',
username: '13800138000',
contact: 'user@example.com'
});
expect(mocks.patch).toHaveBeenCalledWith(
'https://crm.example.com/api/v1/contacts/visitor/visitor%2F1/identity',
{
cloud_user_id: 'user-1',
contact: 'user@example.com'
},
{
headers: { 'X-API-Key': 'crm-key' },
timeout: 5000
}
);
});
it('reports a phone number when no email is available', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
await reportCRMVisitorIdentity({
visitorId: 'visitor-1',
userId: 'user-1',
username: '13800138000'
});
expect(mocks.patch).toHaveBeenCalledWith(
'https://crm.example.com/api/v1/contacts/visitor/visitor-1/identity',
{
cloud_user_id: 'user-1',
contact: '13800138000'
},
expect.any(Object)
);
});
it('does not fail login when CRM reporting fails', async () => {
mocks.serviceEnv.CRM_API_URL = 'https://crm.example.com/api/v1';
mocks.serviceEnv.CRM_API_KEY = 'crm-key';
mocks.patch.mockRejectedValueOnce(new Error('CRM unavailable'));
await expect(
reportCRMVisitorIdentity({
visitorId: 'visitor-1',
userId: 'user-1',
username: 'user@example.com'
})
).resolves.toBeUndefined();
expect(mocks.warn).toHaveBeenCalledWith(
'CRM visitor identity report failed',
expect.objectContaining({ visitorId: 'visitor-1', userId: 'user-1' })
);
});
});
......@@ -22,6 +22,10 @@ ROOT_KEY=fdafasd
# PRO_URL=
# PRO_TOKEN=
# 官网访客归因 CRM(地址未配置时不进行身份上报)
# CRM_API_URL=https://crm.example.com/api/v1
# CRM_API_KEY=
# 插件服务
PLUGIN_BASE_URL=http://localhost:3004
PLUGIN_TOKEN=XHgR8zvKx1FhjHUxCKdJMNpzFUMlavM1
......
......@@ -15,6 +15,7 @@ import type { LangEnum } from '@fastgpt/global/common/i18n/type';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import PolicyTip from './PolicyTip';
import { getRegisterMethods } from '@/web/common/system/utils';
import { getFastGPTSem, onFastGPTLoginSuccess } from '@/web/support/marketing/utils';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
......@@ -50,9 +51,10 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
username,
password,
code,
fastgpt_sem: getFastGPTSem(),
language: i18n.language as LangEnum
});
await loginSuccess(loginResponse);
await onFastGPTLoginSuccess(loginSuccess, loginResponse);
},
{
refreshDeps: [loginSuccess],
......
......@@ -13,8 +13,8 @@ import {
getBdVId,
getFastGPTSem,
getMsclkid,
removeFastGPTSem,
getInviterId
getInviterId,
onFastGPTLoginSuccess
} from '@/web/support/marketing/utils';
import PolicyTip from './PolicyTip';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
......@@ -56,8 +56,7 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => {
enabled: !!wechatInfo?.code,
async onSuccess(data: LoginSuccessResponseType | undefined) {
if (data) {
removeFastGPTSem();
await loginSuccess(data);
await onFastGPTLoginSuccess(loginSuccess, data);
}
}
}
......
......@@ -13,7 +13,7 @@ import {
getFastGPTSem,
getInviterId,
getMsclkid,
removeFastGPTSem
onFastGPTLoginSuccess
} from '@/web/support/marketing/utils';
import { checkPasswordRule } from '@fastgpt/global/common/string/password';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
......@@ -64,8 +64,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
fastgpt_sem: getFastGPTSem(),
language: i18n.language as LangEnum
});
await loginSuccess(loginResponse);
removeFastGPTSem();
await onFastGPTLoginSuccess(loginSuccess, loginResponse);
toast({
status: 'success',
......
......@@ -21,12 +21,16 @@ import {
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type';
import { getClientIpFromRequest } from '@fastgpt/service/common/security/clientIp';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
reportCRMVisitorIdentity,
resolveCRMVisitorId
} from '@fastgpt/service/support/marketing/attribution';
async function handler(
req: ApiRequestProps<LoginByPasswordBodyType>,
res: ApiResponseType
): Promise<LoginSuccessResponseType> {
const { username, password, code, language } = parseApiInput({
const { username, password, code, language, fastgpt_sem } = parseApiInput({
req,
bodySchema: LoginByPasswordBodySchema
}).body;
......@@ -64,6 +68,13 @@ async function handler(
user.lastLoginTmbId = userDetail.team.tmbId;
user.language = language;
const visitorIdentity = resolveCRMVisitorId({
storedFastgptSem: user.fastgpt_sem,
incomingVisitorId: fastgpt_sem?.visitor_id
});
if (visitorIdentity.shouldPersist) {
user.fastgpt_sem = visitorIdentity.fastgptSem;
}
await user.save();
const token = await createUserSession({
......@@ -76,6 +87,13 @@ async function handler(
setCookie(res, token);
void reportCRMVisitorIdentity({
visitorId: visitorIdentity.visitorId,
userId: String(user._id),
username: user.username,
contact: user.contact
});
pushTrack.login({
type: 'password',
uid: user._id,
......
......@@ -12,6 +12,7 @@ import { validateRedirectUrl } from '@/web/common/utils/uri';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect';
import type { LangEnum } from '@fastgpt/global/common/i18n/type';
import { getFastGPTSem, onFastGPTLoginSuccess } from '@/web/support/marketing/utils';
const FastLogin = ({
code,
......@@ -55,6 +56,7 @@ const FastLogin = ({
const res = await postFastLogin({
code,
token,
fastgpt_sem: getFastGPTSem(),
language: i18n.language as LangEnum
});
if (!res) {
......@@ -66,7 +68,7 @@ const FastLogin = ({
router.replace('/login');
}, 1000);
}
await loginSuccess(res);
await onFastGPTLoginSuccess(loginSuccess, res);
} catch (error) {
toast({
status: 'warning',
......
......@@ -15,7 +15,7 @@ import {
getFastGPTSem,
getInviterId,
getMsclkid,
removeFastGPTSem
onFastGPTLoginSuccess
} from '@/web/support/marketing/utils';
import { postAcceptInvitationLink } from '@/web/support/user/team/api';
import { retryFn } from '@fastgpt/global/common/system/utils';
......@@ -103,8 +103,7 @@ const provider = () => {
}, 1000);
}
removeFastGPTSem();
await loginSuccess(res);
await onFastGPTLoginSuccess(loginSuccess, res);
} catch (error) {
toast({
status: 'warning',
......
......@@ -26,7 +26,7 @@ type MarketingQueryParams = {
msclkid?: string;
k?: string;
search?: string;
fastgpt_source?: string;
visitor_id?: string;
sourceDomain?: string;
utm_source?: string;
utm_medium?: string;
......@@ -40,7 +40,7 @@ const MARKETING_PARAMS: (keyof MarketingQueryParams)[] = [
'bd_vid',
'msclkid',
'k',
'fastgpt_source',
'visitor_id',
'sourceDomain',
'utm_source',
'utm_medium',
......@@ -57,7 +57,7 @@ export const useInitApp = () => {
msclkid,
k,
search,
fastgpt_source,
visitor_id,
sourceDomain,
utm_source,
utm_medium,
......@@ -165,10 +165,11 @@ export const useInitApp = () => {
if (utm_workflow) {
setUtmParams(utmParams);
}
setFastGPTSem({
keyword: k,
search,
source: fastgpt_source,
visitor_id,
...utmParams
});
......
import {
FastGPT_SEM_Schema,
type ShortUrlParams,
type TrackRegisterParams
} from '@fastgpt/global/support/marketing/type';
......@@ -59,15 +60,30 @@ export const removeUtmParams = () => {
localStorage.removeItem('utm_params');
};
export const getFastGPTSem = () => {
export const getFastGPTSem = (): TrackRegisterParams['fastgpt_sem'] => {
try {
return localStorage.getItem('fastgpt_sem')
? JSON.parse(localStorage.getItem('fastgpt_sem')!)
: undefined;
const value = localStorage.getItem('fastgpt_sem');
if (!value) return undefined;
const result = FastGPT_SEM_Schema.safeParse(JSON.parse(value));
if (result.success) return result.data;
localStorage.removeItem('fastgpt_sem');
return undefined;
} catch {
localStorage.removeItem('fastgpt_sem');
return undefined;
}
};
export const onFastGPTLoginSuccess = async <T>(
loginSuccess: (result: T) => void | Promise<void>,
result: T
) => {
await loginSuccess(result);
removeFastGPTSem();
};
export const setFastGPTSem = (fastgptSem?: TrackRegisterParams['fastgpt_sem']) => {
if (!fastgptSem) return;
......@@ -76,14 +92,13 @@ export const setFastGPTSem = (fastgptSem?: TrackRegisterParams['fastgpt_sem']) =
const currentFastGPTSem = getFastGPTSem();
const nextFastGPTSem = Object.fromEntries(validEntries);
const result = FastGPT_SEM_Schema.safeParse({
...currentFastGPTSem,
...nextFastGPTSem
});
localStorage.setItem(
'fastgpt_sem',
JSON.stringify({
...currentFastGPTSem,
...nextFastGPTSem
})
);
if (!result.success) return;
localStorage.setItem('fastgpt_sem', JSON.stringify(result.data));
};
export const removeFastGPTSem = () => {
localStorage.removeItem('fastgpt_sem');
......
......@@ -49,7 +49,7 @@ describe('loginByPassword API', () => {
});
it('should login successfully with valid credentials', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
......@@ -84,7 +84,7 @@ describe('loginByPassword API', () => {
});
it('should reject login when username is empty', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: '',
password: 'testpassword',
......@@ -97,7 +97,7 @@ describe('loginByPassword API', () => {
});
it('should reject login when password is empty', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: '',
......@@ -114,7 +114,7 @@ describe('loginByPassword API', () => {
it('should reject login when auth code verification fails', async () => {
vi.mocked(authCode).mockRejectedValueOnce(new Error('Invalid code'));
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
......@@ -128,7 +128,7 @@ describe('loginByPassword API', () => {
});
it('should reject login when user does not exist', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'nonexistentuser',
password: 'testpassword',
......@@ -146,7 +146,7 @@ describe('loginByPassword API', () => {
status: UserStatusEnum.forbidden
});
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
......@@ -160,7 +160,7 @@ describe('loginByPassword API', () => {
});
it('should reject login when password is incorrect', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'wrongpassword',
......@@ -174,7 +174,7 @@ describe('loginByPassword API', () => {
});
it('should update language on successful login', async () => {
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
......@@ -190,6 +190,48 @@ describe('loginByPassword API', () => {
expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id);
});
it('should persist visitor_id on successful login', async () => {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456',
fastgpt_sem: {
visitor_id: 'visitor-1'
},
language: 'zh-CN'
}
});
expect(res.code).toBe(200);
const updatedUser = await MongoUser.findById(testUser._id).lean();
expect(updatedUser?.fastgpt_sem).toMatchObject({ visitor_id: 'visitor-1' });
});
it('should keep the stored visitor_id when login carries a different one', async () => {
await MongoUser.findByIdAndUpdate(testUser._id, {
fastgpt_sem: { visitor_id: 'stored-visitor' }
});
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456',
fastgpt_sem: {
visitor_id: 'incoming-visitor'
},
language: 'zh-CN'
}
});
expect(res.code).toBe(200);
const updatedUser = await MongoUser.findById(testUser._id).lean();
expect(updatedUser?.fastgpt_sem).toMatchObject({ visitor_id: 'stored-visitor' });
});
it('should handle root user login correctly', async () => {
const rootUser = await MongoUser.create({
username: 'root',
......@@ -217,7 +259,7 @@ describe('loginByPassword API', () => {
lastLoginTmbId: rootTmb._id
});
const res = await Call<LoginByPasswordBodyType, {}, any>(loginApi.default, {
const res = await Call<LoginByPasswordBodyType, Record<string, never>, any>(loginApi.default, {
body: {
username: 'root',
password: 'rootpassword',
......@@ -236,7 +278,7 @@ describe('loginByPassword API', () => {
describe('NoSQL injection prevention', () => {
it('should reject password as object with MongoDB operator ($ne)', async () => {
// GHSA-jxvr-h2vx-p73r Step 2: password: {"$ne": ""} bypasses password check
const res = await Call<any, {}, any>(loginApi.default, {
const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: { $ne: '' },
......@@ -252,7 +294,7 @@ describe('loginByPassword API', () => {
});
it('should reject password with $regex operator', async () => {
const res = await Call<any, {}, any>(loginApi.default, {
const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: { $regex: '.*' },
......@@ -265,7 +307,7 @@ describe('loginByPassword API', () => {
});
it('should reject password with $where injection', async () => {
const res = await Call<any, {}, any>(loginApi.default, {
const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: { $where: 'return true' },
......@@ -278,7 +320,7 @@ describe('loginByPassword API', () => {
});
it('should reject username as object with MongoDB operator', async () => {
const res = await Call<any, {}, any>(loginApi.default, {
const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: { $ne: '' },
password: 'testpassword',
......@@ -291,7 +333,7 @@ describe('loginByPassword API', () => {
});
it('should reject code as object with MongoDB operator', async () => {
const res = await Call<any, {}, any>(loginApi.default, {
const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
......@@ -304,7 +346,7 @@ describe('loginByPassword API', () => {
});
it('should reject all fields as injection objects simultaneously', async () => {
const res = await Call<any, {}, any>(loginApi.default, {
const res = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: { $ne: '' },
password: { $ne: '' },
......@@ -317,7 +359,7 @@ describe('loginByPassword API', () => {
});
it('should reject password as non-string types (array, number)', async () => {
const arrayRes = await Call<any, {}, any>(loginApi.default, {
const arrayRes = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: ['testpassword'],
......@@ -327,7 +369,7 @@ describe('loginByPassword API', () => {
});
expect(arrayRes.code).toBe(500);
const numberRes = await Call<any, {}, any>(loginApi.default, {
const numberRes = await Call<any, Record<string, never>, any>(loginApi.default, {
body: {
username: 'testuser',
password: 12345,
......
......@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
getFastGPTSem,
initFastGPTSemSourceDomain,
removeFastGPTSem
onFastGPTLoginSuccess,
removeFastGPTSem,
setFastGPTSem
} from '@/web/support/marketing/utils';
const storageMock = () => {
......@@ -38,4 +40,49 @@ describe('marketing utils', () => {
expect(getFastGPTSem()?.sourceDomain).toBe('https://example.com');
});
it('should persist visitor_id without source attribution fields', () => {
setFastGPTSem({
visitor_id: ' visitor-1 '
});
expect(getFastGPTSem()).toEqual({
visitor_id: 'visitor-1'
});
});
it('should not persist an oversized visitor_id', () => {
setFastGPTSem({
visitor_id: 'a'.repeat(65)
});
expect(localStorage.getItem('fastgpt_sem')).toBeNull();
expect(getFastGPTSem()).toBeUndefined();
});
it('should discard unknown marketing fields', () => {
localStorage.setItem(
'fastgpt_sem',
JSON.stringify({
visitor_id: 'visitor-current',
unknown_field: 'discarded'
})
);
expect(getFastGPTSem()).toEqual({
visitor_id: 'visitor-current'
});
});
it('should clear pending marketing data after login succeeds', async () => {
setFastGPTSem({
visitor_id: 'visitor-1'
});
const loginSuccess = vi.fn();
await onFastGPTLoginSuccess(loginSuccess, { ok: true });
expect(loginSuccess).toHaveBeenCalledWith({ ok: true });
expect(getFastGPTSem()).toBeUndefined();
});
});
......@@ -50,7 +50,7 @@ describe('user api', () => {
msclkid: 'click123',
fastgpt_sem: {
keyword: 'sem123',
source: 'home_hero_trial',
visitor_id: 'visitor-1',
sourceDomain: 'https://example.com'
}
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment