From 5967bd8e08db9e5069224978ae78b7023efb43e8 Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 10 Aug 2026 14:33:03 +0800 Subject: [PATCH] chore: tighten education verification and pause discount access (#40402) Co-authored-by: Yansong Zhang <916125788@qq.com> --- api/controllers/console/error.py | 6 + api/controllers/console/workspace/account.py | 13 +- .../console/test_workspace_account.py | 23 ++++ .../education-verification-flow.test.tsx | 83 +++-------- web/app/(commonLayout)/page.spec.tsx | 36 +++++ web/app/(commonLayout)/page.tsx | 44 +++++- .../billing/plan/__tests__/index.spec.tsx | 129 ++++++++++++++++++ web/app/components/billing/plan/index.tsx | 29 ++++ .../__tests__/education-apply-page.spec.tsx | 47 ++++++- .../education-apply/education-apply-page.tsx | 20 ++- web/i18n/ar-TN/education.json | 5 + web/i18n/de-DE/education.json | 5 + web/i18n/en-US/education.json | 5 + web/i18n/es-ES/education.json | 5 + web/i18n/fa-IR/education.json | 5 + web/i18n/fr-FR/education.json | 5 + web/i18n/hi-IN/education.json | 5 + web/i18n/id-ID/education.json | 5 + web/i18n/it-IT/education.json | 5 + web/i18n/ja-JP/education.json | 5 + web/i18n/ko-KR/education.json | 5 + web/i18n/lo-LA/education.json | 5 + web/i18n/nl-NL/education.json | 5 + web/i18n/pl-PL/education.json | 5 + web/i18n/pt-BR/education.json | 5 + web/i18n/ro-RO/education.json | 5 + web/i18n/ru-RU/education.json | 5 + web/i18n/sl-SI/education.json | 5 + web/i18n/th-TH/education.json | 5 + web/i18n/tr-TR/education.json | 5 + web/i18n/uk-UA/education.json | 5 + web/i18n/vi-VN/education.json | 5 + web/i18n/zh-Hans/education.json | 5 + web/i18n/zh-Hant/education.json | 5 + 34 files changed, 472 insertions(+), 78 deletions(-) create mode 100644 web/app/(commonLayout)/page.spec.tsx create mode 100644 web/app/components/billing/plan/__tests__/index.spec.tsx diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py index e4352f92f88..638af52e284 100644 --- a/api/controllers/console/error.py +++ b/api/controllers/console/error.py @@ -109,6 +109,12 @@ class EducationActivateLimitError(BaseHTTPException): code = 429 +class EducationDiscountTemporarilyPausedError(BaseHTTPException): + error_code = "education_discount_temporarily_paused" + description = "Education discount temporarily paused, while we upgrade our security measures." + code = 503 + + class ComplianceRateLimitError(BaseHTTPException): error_code = "compliance_rate_limit" description = "Rate limit exceeded for downloading compliance report." diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 490d2e3a1e1..a1fcbf976a2 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -28,7 +28,12 @@ from controllers.console.auth.error import ( InvalidEmailError, InvalidTokenError, ) -from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError +from controllers.console.error import ( + AccountInFreezeError, + AccountNotFound, + EducationDiscountTemporarilyPausedError, + EmailSendIpLimitError, +) from controllers.console.workspace.error import ( AccountAlreadyInitedError, CurrentPasswordIncorrectError, @@ -557,11 +562,7 @@ class EducationApi(Resource): @cloud_edition_billing_enabled @with_current_user def post(self, account: Account): - payload = console_ns.payload or {} - args = EducationActivatePayload.model_validate(payload) - - result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role) - return result + raise EducationDiscountTemporarilyPausedError() @setup_required @login_required diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index 40a3f06ad29..ce10cc9e070 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -8,12 +8,14 @@ import pytest from flask import Flask from sqlalchemy.orm import Session, scoped_session, sessionmaker +from controllers.console.error import EducationDiscountTemporarilyPausedError from controllers.console.workspace.account import ( AccountDeleteUpdateFeedbackApi, ChangeEmailCheckApi, ChangeEmailResetApi, ChangeEmailSendEmailApi, CheckEmailUnique, + EducationApi, ) from models import Account, AccountIntegrate, AccountStatus, Tenant, TenantAccountJoin from models.account import TenantAccountRole @@ -106,6 +108,27 @@ def _build_change_email_token( raise AssertionError(f"Unsupported phase for test helper: {phase}") +class TestEducationApi: + @patch("controllers.console.workspace.account.BillingService.EducationIdentity.activate") + def test_post_returns_temporarily_paused_error_without_activating_discount( + self, mock_activate: MagicMock, app: Flask + ): + account = _build_account("student@example.edu") + + with app.test_request_context("/account/education", method="POST", json={}): + api = EducationApi() + method = inspect.unwrap(api.post) + with pytest.raises(EducationDiscountTemporarilyPausedError) as exc_info: + method(api, account) + + assert exc_info.value.data == { + "code": "education_discount_temporarily_paused", + "message": "Education discount temporarily paused, while we upgrade our security measures.", + "status": 503, + } + mock_activate.assert_not_called() + + class TestChangeEmailSend: @patch("controllers.console.workspace.account.AccountService.send_change_email_email") @patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 01c1ef9a488..8d578b9c5c9 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -4,8 +4,7 @@ import type { ReactElement } from 'react' * Integration test: Education Verification Flow * * Tests the education plan verification flow in PlanComp: - * PlanComp → handleVerify → useEducationVerify → router.push → education-apply - * PlanComp → handleVerify → error → show VerifyStateModal + * PlanComp → handleVerify → show temporary pause notice * * Also covers education button visibility based on context flags. */ @@ -102,14 +101,16 @@ vi.mock('@/app/education-apply/verify-state-modal', () => ({ }: { isShow: boolean title?: string - content?: string + content?: React.ReactNode email?: string showLink?: boolean }) => isShow ? (
{title && {title}} - {content && {content}} + {content !== undefined && content !== null ? ( + {content} + ) : null} {email && {email}} {showLink && link}
@@ -208,67 +209,9 @@ describe('Education Verification Flow', () => { }) }) - // ─── 2. Successful Verification Flow ──────────────────────────────────── - describe('Successful verification flow', () => { - it('should let non-manager members start education verification', async () => { - mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' }) - setupContexts({}, { enableEducationPlan: true }, { isCurrentWorkspaceManager: false }) - const user = userEvent.setup() - - render() - - const verifyButton = screen.getByText(/toVerified/i) - await user.click(verifyButton) - - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledTimes(1) - expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123') - }) - }) - }) - - // ─── 3. Failed Verification Flow ──────────────────────────────────────── - describe('Failed verification flow', () => { - it('should show VerifyStateModal with rejection info on error', async () => { - mockMutateAsync.mockRejectedValue(new Error('Verification failed')) - setupContexts({}, { enableEducationPlan: true }) - const user = userEvent.setup() - - render() - - // Modal should not be visible initially - expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument() - - const verifyButton = screen.getByText(/toVerified/i) - await user.click(verifyButton) - - // Modal should appear after verification failure - await waitFor(() => { - expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() - }) - - // Modal should display rejection title and content - expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i) - expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i) - }) - - it('should show email and link in VerifyStateModal', async () => { - mockMutateAsync.mockRejectedValue(new Error('fail')) - setupContexts({}, { enableEducationPlan: true }) - const user = userEvent.setup() - - render() - - await user.click(screen.getByText(/toVerified/i)) - - await waitFor(() => { - expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu') - expect(screen.getByTestId('modal-show-link')).toBeInTheDocument() - }) - }) - - it('should not redirect on verification failure', async () => { - mockMutateAsync.mockRejectedValue(new Error('fail')) + // ─── 2. Temporarily Paused Verification Flow ──────────────────────────── + describe('Temporarily paused verification flow', () => { + it('should show the pause notice without starting verification', async () => { setupContexts({}, { enableEducationPlan: true }) const user = userEvent.setup() @@ -280,12 +223,18 @@ describe('Education Verification Flow', () => { expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() }) - // Should NOT navigate + expect(screen.getByTestId('modal-title')).toHaveTextContent(/educationDiscountPaused.title/i) + expect(screen.getByTestId('modal-content')).toHaveTextContent( + /educationDiscountPaused.description/i, + ) + expect(screen.queryByTestId('modal-email')).not.toBeInTheDocument() + expect(screen.queryByTestId('modal-show-link')).not.toBeInTheDocument() + expect(mockMutateAsync).not.toHaveBeenCalled() expect(mockRouterPush).not.toHaveBeenCalled() }) }) - // ─── 4. Education + Upgrade Coexistence ───────────────────────────────── + // ─── 3. Education + Upgrade Coexistence ───────────────────────────────── describe('Education and upgrade button coexistence', () => { it('should show both education verify and upgrade buttons for sandbox user', () => { setupContexts({ type: Plan.sandbox }, { enableEducationPlan: true }) diff --git a/web/app/(commonLayout)/page.spec.tsx b/web/app/(commonLayout)/page.spec.tsx new file mode 100644 index 00000000000..03aeba335c8 --- /dev/null +++ b/web/app/(commonLayout)/page.spec.tsx @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + redirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), +})) + +vi.mock('@/features/home/page', () => ({ + HomePage: () => null, +})) + +vi.mock('@/next/navigation', () => ({ + redirect: (url: string) => mocks.redirect(url), +})) + +describe('Home route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens billing for the legacy education verification action', async () => { + const { default: Page } = await import('./page') + + await expect( + Page({ + searchParams: Promise.resolve({ + action: 'getEducationVerify', + utm_source: 'education-email', + }), + }), + ).rejects.toThrow('NEXT_REDIRECT') + + expect(mocks.redirect).toHaveBeenCalledWith('/?settings=billing&utm_source=education-email') + }) +}) diff --git a/web/app/(commonLayout)/page.tsx b/web/app/(commonLayout)/page.tsx index 5933845b8b4..16585981ce5 100644 --- a/web/app/(commonLayout)/page.tsx +++ b/web/app/(commonLayout)/page.tsx @@ -1,5 +1,47 @@ +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { HomePage } from '@/features/home/page' +import { redirect } from '@/next/navigation' + +type HomeSearchParams = Record + +type PageProps = { + searchParams?: Promise +} + +const LEGACY_EDUCATION_VERIFY_ACTION = 'getEducationVerify' +const SETTINGS_QUERY_PARAM_NAME = 'settings' + +const getFirstSearchParamValue = (value: string | string[] | undefined) => { + if (Array.isArray(value)) return value[0] + + return value +} + +const getEducationVerifyRedirectPath = (searchParams: HomeSearchParams) => { + const redirectSearchParams = new URLSearchParams({ + [SETTINGS_QUERY_PARAM_NAME]: ACCOUNT_SETTING_TAB.BILLING, + }) + + Object.entries(searchParams).forEach(([key, value]) => { + if (key === 'action' || key === SETTINGS_QUERY_PARAM_NAME || value === undefined) return + + if (Array.isArray(value)) { + value.forEach((item) => redirectSearchParams.append(key, item)) + return + } + + redirectSearchParams.append(key, value) + }) + + return `/?${redirectSearchParams.toString()}` +} + +export default async function Page({ searchParams }: PageProps) { + const resolvedSearchParams = (await searchParams) ?? {} + const action = getFirstSearchParamValue(resolvedSearchParams.action) + + if (action === LEGACY_EDUCATION_VERIFY_ACTION) + redirect(getEducationVerifyRedirectPath(resolvedSearchParams)) -export default function Page() { return } diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx new file mode 100644 index 00000000000..caced120d0a --- /dev/null +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -0,0 +1,129 @@ +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { baseProviderContextValue } from '@/context/provider-context' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { render } from '@/test/console/render' +import PlanComp from '../index' + +const mocks = vi.hoisted(() => ({ + mutateAsync: vi.fn(), + push: vi.fn(), +})) + +vi.mock('@/context/account-state', async () => { + const { createAccountStateModuleMock } = await import('@/test/console/state-fixture') + return createAccountStateModuleMock(() => ({ + userProfile: { email: 'user@example.com' }, + })) +}) + +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + return createWorkspaceStateModuleMock(() => ({ + isCurrentWorkspaceManager: false, + })) +}) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: () => ({ + ...baseProviderContextValue, + enableEducationPlan: true, + }), + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + isPending: false, + mutateAsync: mocks.mutateAsync, + }), +})) + +vi.mock('@/app/components/billing/hooks/use-education-discount', () => ({ + useEducationDiscount: () => ({ + handleEducationDiscount: vi.fn(), + isEducationDiscountLoading: false, + }), +})) + +vi.mock('@/app/components/billing/upgrade-btn', () => ({ + default: () => , +})) + +vi.mock('@/app/components/billing/usage-info', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/billing/usage-info/apps-info', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/billing/usage-info/vector-space-info', () => ({ + default: () => null, +})) + +vi.mock('../assets', () => ({ + Enterprise: () => null, + Professional: () => null, + Sandbox: () => null, + Team: () => null, +})) + +const renderPlan = (educationStatus = { allow_refresh: false, is_student: false }) => { + const { wrapper } = createConsoleQueryWrapper({ + educationStatus, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + + return render(, { wrapper }) +} + +describe('PlanComp education discount pause', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows education verification before View Plan when the original eligibility allows it', () => { + renderPlan() + + const educationButton = screen.getByRole('button', { name: 'education.toVerified' }) + const viewPlanButton = screen.getByRole('button', { name: 'View Plan' }) + + expect( + educationButton.compareDocumentPosition(viewPlanButton) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) + + it('hides education verification for a verified account that is not expiring', () => { + renderPlan({ allow_refresh: false, is_student: true }) + + expect(screen.queryByRole('button', { name: 'education.toVerified' })).not.toBeInTheDocument() + }) + + it('shows the pause notice instead of starting verification and closes it with OK', async () => { + const user = userEvent.setup() + renderPlan() + + await user.click(screen.getByRole('button', { name: 'education.toVerified' })) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveTextContent('education.educationDiscountPaused.title') + expect(dialog).toHaveTextContent('education.educationDiscountPaused.description') + expect(dialog).toHaveTextContent('education.educationDiscountPaused.thanks') + expect(dialog).toHaveTextContent('education.educationDiscountPaused.publishedAt') + expect(within(dialog).getAllByRole('button')).toHaveLength(1) + expect(within(dialog).getByRole('button')).toHaveAccessibleName('common.operation.ok') + expect(mocks.mutateAsync).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 7b8e8b0e9e8..aa87dd50222 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -37,6 +37,9 @@ const selectEducationPlanStatus = ({ allow_refresh, is_student }: EducationStatu isEducationAccount: is_student ?? false, }) +// TODO: Remove this temporary gate once education applications and redemptions reopen. +const EDUCATION_DISCOUNT_TEMPORARILY_PAUSED = true + const PlanComp: FC = ({ loc }) => { const { t } = useTranslation() const { data: deploymentEdition } = useSuspenseQuery({ @@ -71,10 +74,17 @@ const PlanComp: FC = ({ loc }) => { })() const [showModal, setShowModal] = React.useState(false) + const [showEducationDiscountPausedModal, setShowEducationDiscountPausedModal] = + React.useState(false) const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount() const { mutateAsync, isPending } = useEducationVerify() const unmountedRef = useUnmountedRef() const handleVerify = () => { + if (EDUCATION_DISCOUNT_TEMPORARILY_PAUSED) { + setShowEducationDiscountPausedModal(true) + return + } + if (isPending) return mutateAsync() .then((res) => { @@ -175,6 +185,25 @@ const PlanComp: FC = ({ loc }) => { resetInDays={apiRateLimitResetInDays} /> + $['educationDiscountPaused.title'], { ns: 'education' })} + content={ + <> + + {t(($) => $['educationDiscountPaused.description'], { ns: 'education' })} + + + {t(($) => $['educationDiscountPaused.thanks'], { ns: 'education' })} + + + {t(($) => $['educationDiscountPaused.publishedAt'], { ns: 'education' })} + + + } + onConfirm={() => setShowEducationDiscountPausedModal(false)} + onCancel={() => setShowEducationDiscountPausedModal(false)} + /> ({ useLogout: () => ({ mutateAsync: vi.fn() }), })) +vi.mock('@/service/use-education', () => ({ + useEducationAutocomplete: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ data: [], has_next: false }), + isPending: false, + data: undefined, + }), +})) + vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => vi.fn(), })) @@ -125,9 +133,9 @@ const setupContext = (isCurrentWorkspaceManager: boolean) => { } } -const renderPage = () => { +const renderPage = (isEducationAccount = true) => { const { wrapper } = createConsoleQueryWrapper({ - educationStatus: { is_student: true }, + educationStatus: { is_student: isEducationAccount }, workspacePermissionKeys: null, }) return render(, { @@ -221,4 +229,39 @@ describe('EducationApplyPage billing boundary', () => { }) expect(globalThis.location.reload).not.toHaveBeenCalled() }) + + it('requires every education agreement before submitting an application', async () => { + setupContext(true) + mockEducationAdd.mockResolvedValue({ message: 'success' }) + const user = userEvent.setup() + renderPage(false) + + const submitButton = screen.getByRole('button', { name: 'education.submit' }) + await user.type( + screen.getByPlaceholderText('education.form.schoolName.placeholder'), + 'DifyUniversity', + ) + await user.click(screen.getByRole('checkbox', { name: 'education.form.terms.option.age' })) + await user.click(screen.getByRole('checkbox', { name: 'education.form.terms.option.inSchool' })) + + expect(submitButton).toBeDisabled() + + await user.click( + screen.getByRole('checkbox', { name: 'education.form.terms.option.personalUse' }), + ) + + expect(submitButton).toBeEnabled() + + await user.click(submitButton) + + await waitFor(() => { + expect(mockEducationAdd.mock.calls[0]?.[0]).toEqual({ + body: { + token: 'education-token', + role: 'Student', + institution: 'DifyUniversity', + }, + }) + }) + }) }) diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 090536d684a..1e18b8e8a58 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -36,6 +36,7 @@ const EducationApplyAgeContent = () => { const [role, setRole] = useState('Student') const [ageChecked, setAgeChecked] = useState(false) const [inSchoolChecked, setInSchoolChecked] = useState(false) + const [personalUseChecked, setPersonalUseChecked] = useState(false) const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false) const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false) const { isPending, mutateAsync: educationAdd } = useMutation( @@ -262,7 +263,7 @@ const EducationApplyAgeContent = () => { /> {t(($) => $['form.terms.option.age'], { ns: 'education' })} -