From 76ad38b120be7c27ea12bd32d51488fbb9f21a7d Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 14 Oct 2026 17:40:47 +0800 Subject: [PATCH 01/23] test: stabilize stream assertions and browser queries --- relay/helper/stream_scanner_test.go | 87 +++++++++++++------ web/default/scripts/run-tests.mjs | 9 ++ .../api-key-routing-editor.test.tsx | 67 ++++++++++---- .../src/features/smart-ops/alerts.test.tsx | 18 ++-- 4 files changed, 124 insertions(+), 57 deletions(-) diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index 2b49753b..69896079 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -302,58 +302,91 @@ func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) { t.Parallel() const numChunks = 50 - const upstreamDelay = 10 * time.Millisecond - const handlerDelay = 20 * time.Millisecond - pr, pw := io.Pipe() - var upstreamBusy atomic.Int64 + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + handlerEntered := make(chan struct{}) + releaseHandler := make(chan struct{}) + readAhead := make(chan struct{}) + producerDone := make(chan struct{}) + done := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { + cancel() + releaseOnce.Do(func() { close(releaseHandler) }) + _ = pr.Close() + _ = pw.Close() + for _, worker := range []<-chan struct{}{producerDone, done} { + select { + case <-worker: + case <-time.After(time.Second): + t.Error("stream test worker did not stop after cancellation") + } + } + }) go func() { + defer close(producerDone) defer pw.Close() - feedStart := time.Now() for i := 0; i < numChunks; i++ { - fmt.Fprintf(pw, "data: {\"id\":%d}\n", i) - time.Sleep(upstreamDelay) + if _, err := fmt.Fprintf(pw, "data: {\"id\":%d}\n", i); err != nil { + return + } + if i == 1 { + close(readAhead) + } } fmt.Fprint(pw, "data: [DONE]\n") - upstreamBusy.Store(int64(time.Since(feedStart))) }() recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) resp := &http.Response{Body: pr} info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} var count atomic.Int64 - var handlerBusy atomic.Int64 - start := time.Now() - done := make(chan struct{}) + var received []string go func() { + defer close(done) StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { - chunkStart := time.Now() - time.Sleep(handlerDelay) - handlerBusy.Add(int64(time.Since(chunkStart))) - count.Add(1) + if count.Add(1) == 1 { + close(handlerEntered) + select { + case <-releaseHandler: + case <-ctx.Done(): + return + } + } + received = append(received, data) }) - close(done) }() + // A coupled scanner cannot read the second pipe write while its first + // callback is blocked. Synchronize that contract without sleep-speed ratios. + for _, phase := range []struct { + name string + done <-chan struct{} + }{{"first handler", handlerEntered}, {"scanner read-ahead", readAhead}} { + select { + case <-phase.done: + case <-ctx.Done(): + t.Fatalf("timed out waiting for %s", phase.name) + } + } + assert.Equal(t, int64(1), count.Load(), "only the blocked first callback can run") + releaseOnce.Do(func() { close(releaseHandler) }) select { case <-done: - case <-time.After(15 * time.Second): + case <-ctx.Done(): t.Fatal("StreamScannerHandler did not complete in time") } - - elapsed := time.Since(start) assert.Equal(t, int64(numChunks), count.Load()) - - // 用实测耗时估算"耦合"基线,避免系统繁忙时 sleep 超时偏移导致名义值失真 - coupledTime := time.Duration(upstreamBusy.Load() + handlerBusy.Load()) - t.Logf("elapsed=%v, coupled_estimate=%v", elapsed, coupledTime) - - assert.Less(t, elapsed, coupledTime*85/100, - "decoupled elapsed time (%v) should be significantly less than coupled estimate (%v)", elapsed, coupledTime) + assert.Equal(t, numChunks, info.ReceivedResponseCount) + require.Len(t, received, numChunks) + for i, data := range received { + assert.Equal(t, fmt.Sprintf("{\"id\":%d}", i), data) + } + assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason) } func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) { diff --git a/web/default/scripts/run-tests.mjs b/web/default/scripts/run-tests.mjs index e2cbdb71..579060b0 100644 --- a/web/default/scripts/run-tests.mjs +++ b/web/default/scripts/run-tests.mjs @@ -274,6 +274,7 @@ function runTestFiles(files, label, bunArguments) { if (files.length === 0) return 'skipped' console.log(`\n==> ${label} (${files.length} file${files.length === 1 ? '' : 's'})`) + const startedAt = performance.now() const result = Bun.spawnSync({ cmd: buildTestCommand(files, bunArguments), cwd: projectRoot, @@ -281,6 +282,14 @@ function runTestFiles(files, label, bunArguments) { stdout: 'pipe', stderr: 'pipe', }) + console.log( + `MAX_API_TEST_STAGE ${JSON.stringify({ + label, + fileCount: files.length, + exitCode: result.exitCode, + wallMilliseconds: Math.round(performance.now() - startedAt), + })}` + ) writeChildOutput(result) if (result.exitCode === 0) return 'passed' diff --git a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx index 778d35d0..cae1abad 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx @@ -29,6 +29,7 @@ import type { ApiKeyGroupOption } from './api-key-group-combobox' let ApiKeyRoutingEditor: typeof import('./api-key-routing-editor').ApiKeyRoutingEditor let cleanup: typeof import('@testing-library/react/pure').cleanup let fireEvent: typeof import('@testing-library/react/pure').fireEvent +let isInaccessible: typeof import('@testing-library/react/pure').isInaccessible let render: typeof import('@testing-library/react/pure').render let within: typeof import('@testing-library/react/pure').within @@ -87,7 +88,7 @@ before(async (): Promise => { }), }) // React and Base UI must detect the browser before mounting interactive portals. - ;({ cleanup, fireEvent, render, within } = + ;({ cleanup, fireEvent, isInaccessible, render, within } = await import('@testing-library/react/pure')) ;({ ApiKeyRoutingEditor } = await import('./api-key-routing-editor')) }) @@ -147,6 +148,37 @@ function RoutingHarness(props: RoutingHarnessProps): ReactElement { ) } +async function findRoutingOptions( + view: ReturnType +): Promise> { + // Compute each visible option's accessible name once instead of rescanning + // every option for each group. Keep the same role, visibility and name checks. + const names = new Map() + const options = await view.findAllByRole('option', { + name: (name: string, element: Element): boolean => { + names.set(element, name) + return true + }, + }) + const result = new Map() + for (const option of options) { + const name = names.get(option) + assert.ok(name !== undefined) + assert.equal(result.has(name), false, `duplicate option name: ${name}`) + result.set(name, option) + } + return result +} + +function routingOption( + options: Map, + name: string +): HTMLElement { + const option = options.get(name) + assert.ok(option, `missing accessible routing option: ${name}`) + return option +} + describe('ApiKeyRoutingEditor', (): void => { for (const initialGroups of [[], groups]) { test(`clears ${initialGroups.length} stored manual selections when automatic routing is disabled`, async (): Promise => { @@ -164,15 +196,13 @@ describe('ApiKeyRoutingEditor', (): void => { ) fireEvent.click(view.getByRole('combobox', { name: 'Select groups' })) - const options = await view.findAllByRole('option') - assert.equal(options.length, availableGroups.length) + const options = await findRoutingOptions(view) + assert.equal(options.size, availableGroups.length) for (const group of availableGroups) { - const option = view.getByRole('option', { - name: `${group}1x Ratio`, - }) + const option = routingOption(options, `${group}1x Ratio`) assert.notEqual(option.getAttribute('aria-disabled'), 'true') } - fireEvent.click(view.getByRole('option', { name: 'group-141x Ratio' })) + fireEvent.click(routingOption(options, 'group-141x Ratio')) within(view.getByRole('combobox', { name: 'Select groups' })).getByText( 'group-14' ) @@ -225,27 +255,28 @@ describe('ApiKeyRoutingEditor', (): void => { fireEvent.click(trigger) assert.equal(trigger.getAttribute('aria-expanded'), 'true') - const options = await view.findAllByRole('option') - assert.equal(options.length, availableGroups.length) + const options = await findRoutingOptions(view) + assert.equal(options.size, availableGroups.length) for (const group of availableGroups) { - const option = view.getByRole('option', { - name: `${group}${group}1x Ratio`, - }) + const option = routingOption(options, `${group}${group}1x Ratio`) assert.equal( option.getAttribute('aria-disabled'), String(!groups.includes(group)) ) } - fireEvent.click( - view.getByRole('option', { name: 'group-14group-141x Ratio' }) - ) + fireEvent.click(routingOption(options, 'group-14group-141x Ratio')) assert.equal(changedGroups, undefined) - const removeButtons = view.getAllByRole('button', { - name: /^Remove group-/, - }) + const removeButtons = view.getAllByLabelText(/^Remove group-/) assert.equal(removeButtons.length, MAX_MANUAL_ROUTING_GROUPS) for (const removeButton of removeButtons) { + assert.equal(removeButton.tagName, 'BUTTON') + assert.ok( + removeButton.getAttribute('role') === null || + removeButton.getAttribute('role') === 'button' + ) + assert.equal(removeButton.getAttribute('aria-labelledby'), null) + assert.equal(isInaccessible(removeButton), false) assert.equal(trigger.contains(removeButton), false) } diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index 30e5f65e..f75d407b 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -662,22 +662,16 @@ describe('SmartOps active alerts', () => { ) try { - await waitFor(() => { - assert.ok( - within(view.container).getByRole('button', { - name: 'Enter exact quota', - }) - ) - }) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement - assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') - await view.click( + const exactQuotaButton = await waitFor(() => within(view.container).getByRole('button', { name: 'Enter exact quota', }) ) + const causeErrorButton = view.container.querySelector( + 'button' + ) as HTMLButtonElement + assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') + await view.click(exactQuotaButton) const dialog = await waitFor(() => { const currentDialog = document.body.querySelector( From a2276ce341e32cdf8be3399b910eea14305a7513 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 14 Oct 2026 18:28:23 +0800 Subject: [PATCH 02/23] test: resolve reconciliation controls by accessible name --- .../src/features/smart-ops/alerts.test.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index f75d407b..d764f06b 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -133,6 +133,7 @@ function ManualSettlementEvidenceHarness(): ReactElement { return ( <> + @@ -662,14 +663,29 @@ describe('SmartOps active alerts', () => { ) try { - const exactQuotaButton = await waitFor(() => - within(view.container).getByRole('button', { - name: 'Enter exact quota', + const { exactQuotaButton, causeErrorButton } = await waitFor(() => { + // Resolve both controls by role and accessible name in one scan. + const names = new Map() + const buttons = within(view.container).getAllByRole('button', { + name: (name: string, element: Element): boolean => { + names.set(element, name) + return true + }, }) - ) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement + const namedButton = (name: string): HTMLElement => { + const matches = buttons.filter((button) => names.get(button) === name) + assert.equal( + matches.length, + 1, + `expected one accessible ${name} button` + ) + return matches[0] + } + return { + exactQuotaButton: namedButton('Enter exact quota'), + causeErrorButton: namedButton('Cause reconciliation error'), + } + }) assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') await view.click(exactQuotaButton) @@ -742,6 +758,9 @@ describe('SmartOps active alerts', () => { ) try { + const causeErrorButton = within(view.container).getByRole('button', { + name: 'Cause reconciliation error', + }) await view.click( await waitFor(() => within(view.container).getByRole('checkbox', { @@ -779,9 +798,6 @@ describe('SmartOps active alerts', () => { return button }) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') await view.click(causeErrorButton) await waitFor(() => { From 1310e4a1f9b7aa9d7d1897d38dc94f1bb43064b5 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 00:32:11 +0800 Subject: [PATCH 03/23] fix(web): stabilize shared components and editor controls --- web/default/src/components/long-text.tsx | 1 - .../secure-verification-provider.tsx | 43 +--- .../hooks/use-api-token-verification.ts | 8 +- .../hooks/use-secure-verification-gate.ts | 33 ++++ .../verification-context.runtime.test.tsx | 184 ++++++++++++++++++ .../auth/secure-verification/index.ts | 2 + .../lib/verification-context.ts | 30 +++ .../src/features/smart-ops/alerts.test.tsx | 83 ++++---- .../components/settings-page-context.tsx | 18 +- .../components/settings-section.tsx | 2 +- .../use-suppress-settings-section-header.ts | 24 +++ .../lib/settings-page-context.ts | 31 +++ .../tiered-pricing-editor.runtime.test.tsx | 41 +++- .../models/tiered-pricing-editor.tsx | 6 +- .../models/tool-price-settings.tsx | 1 - .../components/common-logs-filter-bar.tsx | 7 +- .../dialogs/user-info-dialog.runtime.test.tsx | 173 ++++++++++++++++ .../components/dialogs/user-info-dialog.tsx | 27 +-- .../components/quota-filter-select.tsx | 13 +- .../components/task-logs-filter-bar.tsx | 7 +- .../features/usage-logs/lib/quota-filter.ts | 25 +++ .../secure-verification-continuation.test.tsx | 2 +- 22 files changed, 625 insertions(+), 136 deletions(-) create mode 100644 web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts create mode 100644 web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx create mode 100644 web/default/src/features/auth/secure-verification/lib/verification-context.ts create mode 100644 web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts create mode 100644 web/default/src/features/system-settings/lib/settings-page-context.ts create mode 100644 web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx create mode 100644 web/default/src/features/usage-logs/lib/quota-filter.ts diff --git a/web/default/src/components/long-text.tsx b/web/default/src/components/long-text.tsx index 353eed3d..531ab8c2 100644 --- a/web/default/src/components/long-text.tsx +++ b/web/default/src/components/long-text.tsx @@ -46,7 +46,6 @@ export function LongText({ useEffect(() => { if (checkOverflow(ref.current)) { - // eslint-disable-next-line react-hooks/set-state-in-effect setIsOverflown(true) return } diff --git a/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx b/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx index 21275666..9cb2e6f6 100644 --- a/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx +++ b/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx @@ -16,62 +16,35 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { - createContext, - useCallback, - useContext, - useMemo, - type ReactElement, - type ReactNode, -} from 'react' +import { useCallback, useMemo, type ReactElement, type ReactNode } from 'react' import { useSecureVerification } from '../hooks/use-secure-verification' -import type { StartVerificationOptions, VerificationMethod } from '../types' +import { SecureVerificationContext } from '../lib/verification-context' +import type { VerificationMethod } from '../types' import { SecureVerificationDialog } from './secure-verification-dialog' type SecureVerificationProviderProps = { children: ReactNode } -interface SecureVerificationContextValue { - withVerification: ( - apiCall: () => Promise, - config?: StartVerificationOptions - ) => Promise -} - -export const SecureVerificationContext = - createContext(null) - -export function useSecureVerificationGate(): SecureVerificationContextValue { - const context = useContext(SecureVerificationContext) - if (!context) { - throw new Error( - 'useSecureVerificationGate must be used within SecureVerificationProvider' - ) - } - return context -} - export function SecureVerificationProvider( props: SecureVerificationProviderProps ): ReactElement { const secureVerification = useSecureVerification() + const executeVerification = secureVerification.executeVerification + const withVerification = secureVerification.withVerification const handleVerification = useCallback( async (method: VerificationMethod, code?: string): Promise => { try { - await secureVerification.executeVerification(method, code) + await executeVerification(method, code) } catch { // The shared verification hook reports the error and keeps retryable // verification failures inside the dialog. } }, - [secureVerification.executeVerification] - ) - const contextValue = useMemo( - () => ({ withVerification: secureVerification.withVerification }), - [secureVerification.withVerification] + [executeVerification] ) + const contextValue = useMemo(() => ({ withVerification }), [withVerification]) return ( diff --git a/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts index 2ea25b09..bf3d6ec4 100644 --- a/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts +++ b/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts @@ -18,7 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useSecureVerificationGate } from '../components/secure-verification-provider' +import { useSecureVerificationGate } from './use-secure-verification-gate' const defaultApiTokenVerificationDescription = 'Confirm your identity before sending an API key to a chat client.' @@ -27,15 +27,15 @@ export function useApiTokenVerification( description?: string ): (apiCall: () => Promise) => Promise { const { t } = useTranslation() - const secureVerificationGate = useSecureVerificationGate() + const withVerification = useSecureVerificationGate().withVerification return useCallback( (apiCall: () => Promise): Promise => - secureVerificationGate.withVerification(apiCall, { + withVerification(apiCall, { scope: 'api_token', title: t('Security verification'), description: description ?? t(defaultApiTokenVerificationDescription), }), - [description, t, secureVerificationGate.withVerification] + [description, t, withVerification] ) } diff --git a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts new file mode 100644 index 00000000..44b11703 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts @@ -0,0 +1,33 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useContext } from 'react' +import { + SecureVerificationContext, + type SecureVerificationContextValue, +} from '../lib/verification-context' + +export function useSecureVerificationGate(): SecureVerificationContextValue { + const context = useContext(SecureVerificationContext) + if (!context) { + throw new Error( + 'useSecureVerificationGate must be used within SecureVerificationProvider' + ) + } + return context +} diff --git a/web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx b/web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx new file mode 100644 index 00000000..197e6968 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx @@ -0,0 +1,184 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useEffect } from 'react' +import type { Root } from 'react-dom/client' +import { createInstance } from 'i18next' +import { JSDOM } from 'jsdom' +import assert from 'node:assert/strict' +import { after, afterEach, beforeEach, test } from 'node:test' +import { renderToString } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import { + SecureVerificationContext, + type SecureVerificationContextValue, +} from '../lib/verification-context' +import type { StartVerificationOptions } from '../types' +import { useApiTokenVerification } from './use-api-token-verification' + +type VerifyCall = ReturnType +const dom = new JSDOM('', { + url: 'http://localhost/', +}) +const i18n = createInstance() +const previousGlobals = new Map() +for (const [key, value] of Object.entries({ + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + IS_REACT_ACT_ENVIRONMENT: true, +})) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) +} +await i18n.init({ lng: 'en', resources: { en: { translation: {} } } }) +const { createRoot } = await import('react-dom/client') +let container: HTMLDivElement +let root: Root + +function GateProbe(props: { capture: (call: VerifyCall) => void }) { + const verifyCall = useApiTokenVerification() + const capture = props.capture + useEffect(() => capture(verifyCall), [capture, verifyCall]) + return null +} + +beforeEach((): void => { + container = dom.window.document.createElement('div') + dom.window.document.body.append(container) + root = createRoot(container) +}) +afterEach(async (): Promise => { + await act(async (): Promise => root.unmount()) + container.remove() +}) +after((): void => { + for (const [key, descriptor] of [...previousGlobals].reverse()) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + dom.window.close() +}) + +async function renderGate( + value: SecureVerificationContextValue, + capture: (call: VerifyCall) => void +): Promise { + await act(async (): Promise => { + root.render( + + + + + + ) + }) +} + +test('requires the verification provider instead of silently bypassing the gate', (): void => { + assert.throws( + () => + renderToString( + + undefined} /> + + ), + /useSecureVerificationGate must be used within SecureVerificationProvider/ + ) +}) + +test('keeps callback identity for the same gate method and uses a replacement method', async (): Promise => { + const captured: VerifyCall[] = [] + const capture = (call: VerifyCall): void => { + captured.push(call) + } + const options: Array = [] + let apiCalls = 0 + const withVerification: SecureVerificationContextValue['withVerification'] = + async (apiCall, config) => { + options.push(config) + return apiCall() + } + await renderGate({ withVerification }, capture) + const first = captured.at(-1) + assert.ok(first) + await renderGate({ withVerification }, capture) + assert.equal(captured.at(-1), first) + assert.equal( + await first(async () => { + apiCalls++ + return 'allowed' + }), + 'allowed' + ) + assert.equal(apiCalls, 1) + assert.deepEqual(options, [ + { + scope: 'api_token', + title: 'Security verification', + description: + 'Confirm your identity before sending an API key to a chat client.', + }, + ]) + + const replacement: SecureVerificationContextValue['withVerification'] = + async () => null + await renderGate({ withVerification: replacement }, capture) + const next = captured.at(-1) + assert.ok(next && next !== first) + assert.equal( + await next(async () => { + apiCalls++ + return 'unexpected' + }), + null + ) + assert.equal(apiCalls, 1, 'cancellation must not run the protected API call') +}) + +test('propagates a gate rejection without retrying the protected operation', async (): Promise => { + const failure = new Error('verification rejected') + let gateCalls = 0 + let apiCalls = 0 + let verifyCall: VerifyCall | undefined + await renderGate( + { + withVerification: async () => { + gateCalls++ + throw failure + }, + }, + (call) => { + verifyCall = call + } + ) + assert.ok(verifyCall) + await assert.rejects( + verifyCall(async () => { + apiCalls++ + return 'unexpected' + }), + (error) => error === failure + ) + assert.equal(gateCalls, 1) + assert.equal(apiCalls, 0) +}) diff --git a/web/default/src/features/auth/secure-verification/index.ts b/web/default/src/features/auth/secure-verification/index.ts index 4568b0e7..5f81a51a 100644 --- a/web/default/src/features/auth/secure-verification/index.ts +++ b/web/default/src/features/auth/secure-verification/index.ts @@ -20,5 +20,7 @@ export * from './api' export * from './types' export * from './hooks/use-api-token-verification' export * from './hooks/use-secure-verification' +export * from './hooks/use-secure-verification-gate' +export * from './lib/verification-context' export * from './components/secure-verification-dialog' export * from './components/secure-verification-provider' diff --git a/web/default/src/features/auth/secure-verification/lib/verification-context.ts b/web/default/src/features/auth/secure-verification/lib/verification-context.ts new file mode 100644 index 00000000..235a5865 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/lib/verification-context.ts @@ -0,0 +1,30 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { createContext } from 'react' +import type { StartVerificationOptions } from '../types' + +export interface SecureVerificationContextValue { + withVerification: ( + apiCall: () => Promise, + config?: StartVerificationOptions + ) => Promise +} + +export const SecureVerificationContext = + createContext(null) diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index d764f06b..5d37afa2 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -1198,13 +1198,31 @@ describe('SmartOps active alerts', () => { ) try { - await waitFor(() => { + const singleZeroButton = await waitFor(() => { const screen = within(view.container) - assert.ok( - screen.getAllByRole('button', { - name: 'Confirm zero-quota settlement', - }).length > 0 - ) + const controls = new Map() + const buttons = screen.getAllByRole('button', { + name: (name, element) => { + if ( + name !== 'Confirm zero-quota settlement' && + name !== 'Review and close selected (0)' + ) + return false + if (element instanceof window.HTMLElement) { + const matches = controls.get(name) ?? [] + matches.push(element) + controls.set(name, matches) + } + return true + }, + }) + const zeroButtons = ( + controls.get('Confirm zero-quota settlement') ?? [] + ).filter((button) => buttons.includes(button)) + const reviewButtons = ( + controls.get('Review and close selected (0)') ?? [] + ).filter((button) => buttons.includes(button)) + assert.ok(zeroButtons.length > 0) assert.equal( screen .getByRole('checkbox', { @@ -1213,31 +1231,21 @@ describe('SmartOps active alerts', () => { .hasAttribute('data-disabled'), false ) - assert.equal( - screen - .getByRole('button', { - name: 'Review and close selected (0)', - }) - .hasAttribute('disabled'), - true - ) + assert.equal(reviewButtons.length, 1) + assert.equal(reviewButtons[0].hasAttribute('disabled'), true) + return zeroButtons[0] }) + await view.click(singleZeroButton) + const cancelledZeroDialog = within(document.body).getByRole('alertdialog') await view.click( - within(view.container).getAllByRole('button', { - name: 'Confirm zero-quota settlement', - })[0] - ) - await view.click( - within(document.body).getByRole('button', { name: 'Cancel' }) + within(cancelledZeroDialog).getByRole('button', { name: 'Cancel' }) ) assert.deepEqual(writes, []) + assert.ok(singleZeroButton.isConnected) + await view.click(singleZeroButton) + const singleZeroDialog = within(document.body).getByRole('alertdialog') await view.click( - within(view.container).getAllByRole('button', { - name: 'Confirm zero-quota settlement', - })[0] - ) - await view.click( - within(document.body).getByRole('button', { + within(singleZeroDialog).getByRole('button', { name: 'Apply zero-quota settlements', }) ) @@ -1266,8 +1274,9 @@ describe('SmartOps active alerts', () => { }) assert.equal(zeroBatchButton.hasAttribute('disabled'), false) await view.click(zeroBatchButton) + const batchZeroDialog = within(document.body).getByRole('alertdialog') await view.click( - within(document.body).getByRole('button', { + within(batchZeroDialog).getByRole('button', { name: 'Apply zero-quota settlements', }) ) @@ -1286,20 +1295,16 @@ describe('SmartOps active alerts', () => { ) }) assert.equal(within(document.body).queryByRole('dialog'), null) - assert.ok( - within(view.container).getAllByRole('button', { - name: 'Enter exact quota', - }).length > 0 - ) + const exactButtons = within(view.container).getAllByRole('button', { + name: 'Enter exact quota', + }) + assert.ok(exactButtons.length > 0) // The H3 zero-quota shortcut must not hide the exact settlement path. // Open the real dialog and verify that the exact input remains available // instead of being replaced by the zero-quota batch action. - await view.click( - within(view.container).getAllByRole('button', { - name: 'Enter exact quota', - })[0] - ) + await view.click(exactButtons[0]) + const exactDialog = within(document.body).getByRole('dialog') const exactQuotaInput = document.getElementById( 'manual-task-actual-quota' ) as HTMLInputElement @@ -1310,7 +1315,7 @@ describe('SmartOps active alerts', () => { assert.equal(exactQuotaInput.max, '100') assert.equal( ( - within(document.body).getByRole('button', { + within(exactDialog).getByRole('button', { name: 'Apply exact settlement', }) as HTMLButtonElement ).disabled, @@ -1318,7 +1323,7 @@ describe('SmartOps active alerts', () => { ) await view.click( - within(document.body).getByRole('button', { name: 'Cancel' }) + within(exactDialog).getByRole('button', { name: 'Cancel' }) ) // The exact dialog was opened from the manual-task rows above. After it // closes, select only the ordinary alert to verify that manual task diff --git a/web/default/src/features/system-settings/components/settings-page-context.tsx b/web/default/src/features/system-settings/components/settings-page-context.tsx index 3d9133da..1ceeb60e 100644 --- a/web/default/src/features/system-settings/components/settings-page-context.tsx +++ b/web/default/src/features/system-settings/components/settings-page-context.tsx @@ -17,7 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { - createContext, useContext, type ComponentProps, type ReactNode, @@ -27,18 +26,7 @@ import { RotateCcw, Save } from 'lucide-react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' - -type SettingsPageContextValue = { - actionsContainer: HTMLDivElement | null - titleStatusContainer: HTMLSpanElement | null - suppressSectionHeader: boolean -} - -const SettingsPageContext = createContext({ - actionsContainer: null, - titleStatusContainer: null, - suppressSectionHeader: false, -}) +import { SettingsPageContext } from '../lib/settings-page-context' type SettingsPageProviderProps = { actionsContainer: HTMLDivElement | null @@ -61,10 +49,6 @@ export function SettingsPageProvider(props: SettingsPageProviderProps) { ) } -export function useSuppressSettingsSectionHeader() { - return useContext(SettingsPageContext).suppressSectionHeader -} - type SettingsPageTitleStatusPortalProps = { children: ReactNode } diff --git a/web/default/src/features/system-settings/components/settings-section.tsx b/web/default/src/features/system-settings/components/settings-section.tsx index c4e1f5c4..6a149c49 100644 --- a/web/default/src/features/system-settings/components/settings-section.tsx +++ b/web/default/src/features/system-settings/components/settings-section.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { cn } from '@/lib/utils' -import { useSuppressSettingsSectionHeader } from './settings-page-context' +import { useSuppressSettingsSectionHeader } from '../hooks/use-suppress-settings-section-header' type SettingsSectionProps = { title: string diff --git a/web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts b/web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts new file mode 100644 index 00000000..15e63094 --- /dev/null +++ b/web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts @@ -0,0 +1,24 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useContext } from 'react' +import { SettingsPageContext } from '../lib/settings-page-context' + +export function useSuppressSettingsSectionHeader(): boolean { + return useContext(SettingsPageContext).suppressSectionHeader +} diff --git a/web/default/src/features/system-settings/lib/settings-page-context.ts b/web/default/src/features/system-settings/lib/settings-page-context.ts new file mode 100644 index 00000000..4e64a817 --- /dev/null +++ b/web/default/src/features/system-settings/lib/settings-page-context.ts @@ -0,0 +1,31 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { createContext } from 'react' + +export type SettingsPageContextValue = { + actionsContainer: HTMLDivElement | null + titleStatusContainer: HTMLSpanElement | null + suppressSectionHeader: boolean +} + +export const SettingsPageContext = createContext({ + actionsContainer: null, + titleStatusContainer: null, + suppressSectionHeader: false, +}) diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx index a840f383..4c0a60ee 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx @@ -123,6 +123,31 @@ function getInputValueByLabel(container: HTMLElement, labelText: string) { return input.value } +function getInputsByLabels( + container: HTMLElement, + labels: readonly string[] +): Map { + const expected = new Set(labels) + const matches = new Map>() + const returned = new Set( + within(container).getAllByLabelText((name, element) => { + if (!expected.has(name)) return false + if (element instanceof dom.window.HTMLInputElement) { + const fields = matches.get(name) ?? new Set() + fields.add(element) + matches.set(name, fields) + } + return true + }) + ) + return new Map( + labels.map((label): [string, HTMLInputElement[]] => [ + label, + [...(matches.get(label) ?? [])].filter((field) => returned.has(field)), + ]) + ) +} + const modelA: ModelRatioData = { name: 'model-a', billingMode: 'tiered_expr', @@ -531,9 +556,14 @@ describe('optional zero prices in the actual editor', () => { /> ) } + let modePicker: HTMLElement | undefined const selectMode = async (name: string): Promise => { + assert.ok( + modePicker?.isConnected, + 'editor mode control must stay mounted' + ) await act(async () => { - fireEvent.keyDown(within(container).getAllByRole('combobox')[0], { + fireEvent.keyDown(modePicker!, { key: 'ArrowDown', }) }) @@ -554,6 +584,9 @@ describe('optional zero prices in the actual editor', () => { ) ) + modePicker = within(container).getByRole('combobox', { + name: 'Editor mode', + }) assert.equal(saved, expected) if (supported) await selectMode('Expression editor') await selectMode('Visual editor') @@ -742,8 +775,12 @@ describe('optional zero prices in the actual editor', () => { 'len < 200000 ? tier("base", p * 3 + c * 15) : tier("tier_2", p * 3 + c * 15)' ) } + const fields = getInputsByLabels( + container, + BILLING_EXTRA_VARS.map((variable) => variable.label) + ) for (const variable of BILLING_EXTRA_VARS) { - const inputs = within(container).getAllByLabelText(variable.label) + const inputs = fields.get(variable.label) ?? [] assert.equal(inputs.length, 2) for (const input of inputs) { assert.equal( diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx index a8efb0ac..af377728 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx @@ -285,7 +285,11 @@ const TieredPricingEditorContent = memo(function TieredPricingEditorContent({ value={editorMode} onValueChange={(value) => handleModeChange(value as EditorMode)} > - + diff --git a/web/default/src/features/system-settings/models/tool-price-settings.tsx b/web/default/src/features/system-settings/models/tool-price-settings.tsx index 3363b788..d097e754 100644 --- a/web/default/src/features/system-settings/models/tool-price-settings.tsx +++ b/web/default/src/features/system-settings/models/tool-price-settings.tsx @@ -109,7 +109,6 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({ useEffect(() => { const prices = parseInitialPrices(defaultValue) const initialRows = objectToRows(prices) - // eslint-disable-next-line react-hooks/set-state-in-effect setRows(initialRows) setJsonText(JSON.stringify(prices, null, 2)) setJsonError('') diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx index b0875e63..3918f837 100644 --- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -46,6 +46,7 @@ import { QUOTA_FILTER_ALL_VALUE, } from '../constants' import { buildSearchParams } from '../lib/filter' +import { isQuotaFilterValue, type QuotaFilterValue } from '../lib/quota-filter' import { getDefaultTimeRange } from '../lib/utils' import type { CommonLogFilters } from '../types' import { CommonLogsStats } from './common-logs-stats' @@ -55,11 +56,7 @@ import { LogsFilterInput, LogsFilterToolbar, } from './logs-filter-toolbar' -import { - isQuotaFilterValue, - QuotaFilterSelect, - type QuotaFilterValue, -} from './quota-filter-select' +import { QuotaFilterSelect } from './quota-filter-select' import { useUsageLogsContext } from './usage-logs-provider' const route = getRouteApi('/_authenticated/usage-logs/$section') diff --git a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx new file mode 100644 index 00000000..e08a4bd9 --- /dev/null +++ b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx @@ -0,0 +1,173 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, type ComponentProps } from 'react' +import type { Root } from 'react-dom/client' +import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import { createInstance } from 'i18next' +import { JSDOM } from 'jsdom' +import assert from 'node:assert/strict' +import { after, afterEach, beforeEach, test } from 'node:test' +import { I18nextProvider } from 'react-i18next' +import type { UserInfo } from '../../types' + +let createRoot: typeof import('react-dom/client').createRoot +let within: typeof import('@testing-library/react/pure').within +let UserInfoDialog: typeof import('./user-info-dialog').UserInfoDialog +let api: typeof import('@/lib/api').api +let originalAdapter: typeof api.defaults.adapter +const dom = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true, +}) +const i18n = createInstance() +const previousGlobals = new Map() +const requests: InternalAxiosRequestConfig[] = [] +const user: UserInfo = { + id: 7, + username: 'lint-user', + display_name: 'Lint User', + quota: 1000000, + used_quota: 0, + request_count: 0, + group: 'default', + aff_code: 'synthetic-invitation', + aff_count: 0, + aff_quota: 0, +} +let container: HTMLDivElement +let root: Root + +async function setup(): Promise { + const window = dom.window + const globals: Record = { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + Event: window.Event, + MouseEvent: window.MouseEvent, + MutationObserver: window.MutationObserver, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + localStorage: window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + } + for (const [key, value] of Object.entries(globals)) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) + } + await i18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: {} } }, + }) + ;({ createRoot } = await import('react-dom/client')) + ;({ within } = await import('@testing-library/react/pure')) + ;({ UserInfoDialog } = await import('./user-info-dialog')) + ;({ api } = await import('@/lib/api')) + originalAdapter = api.defaults.adapter +} + +await setup() + +beforeEach((): void => { + container = dom.window.document.createElement('div') + dom.window.document.body.append(container) + root = createRoot(container) + requests.length = 0 + api.defaults.adapter = async ( + config: InternalAxiosRequestConfig + ): Promise => { + requests.push(config) + assert.equal(config.url, '/api/user/7') + assert.equal(config.method, 'get') + return { + config, + data: { success: true, data: user }, + status: 200, + statusText: 'OK', + headers: {}, + } + } +}) + +afterEach(async (): Promise => { + await act(async (): Promise => root.unmount()) + container.remove() + api.defaults.adapter = originalAdapter +}) + +after((): void => { + for (const [key, descriptor] of [...previousGlobals].reverse()) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + dom.window.close() +}) + +async function renderDialog( + onOpenChange: ComponentProps['onOpenChange'] +): Promise { + await act(async (): Promise => { + root.render( + + + + ) + }) +} + +test('preserves selected user information across unrelated parent updates', async (): Promise => { + await renderDialog(() => undefined) + const screen = within(dom.window.document.body) + const username = screen.getByText('lint-user') + const range = dom.window.document.createRange() + range.selectNodeContents(username) + const selection = dom.window.getSelection() + assert.ok(selection) + selection.removeAllRanges() + selection.addRange(range) + assert.equal(selection.toString(), 'lint-user') + + await renderDialog(() => undefined) + + assert.equal(requests.length, 1, 'parent updates must not refetch the user') + assert.ok( + screen.getByText('lint-user') === username, + 'user fields must stay mounted' + ) + assert.equal( + selection.toString(), + 'lint-user', + 'selected text must stay selected' + ) + assert.ok(screen.getByText('Lint User')) + assert.ok(screen.getByText('synthetic-invitation')) + assert.equal( + screen.getByText('Invited Users').nextElementSibling?.textContent, + '0' + ) +}) diff --git a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx index 68d10bbb..5d8cadc8 100644 --- a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx @@ -38,6 +38,20 @@ interface UserInfoDialogProps { onOpenChange: (open: boolean) => void } +type InfoItemProps = { + label: string + value: string | number +} + +function InfoItem(props: InfoItemProps) { + return ( +
+ +
{props.value}
+
+ ) +} + export function UserInfoDialog({ userId, open, @@ -74,19 +88,6 @@ export function UserInfoDialog({ } }, [open, userId, fetchUserInfo]) - const InfoItem = ({ - label, - value, - }: { - label: string - value: string | number - }) => ( -
- -
{value}
-
- ) - return ( diff --git a/web/default/src/features/usage-logs/components/quota-filter-select.tsx b/web/default/src/features/usage-logs/components/quota-filter-select.tsx index e79c93b7..f10bb62e 100644 --- a/web/default/src/features/usage-logs/components/quota-filter-select.tsx +++ b/web/default/src/features/usage-logs/components/quota-filter-select.tsx @@ -26,17 +26,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { - QUOTA_FILTER_ALL_VALUE, - QUOTA_FILTER_VALUES, - QUOTA_FILTERS, -} from '../constants' - -export type QuotaFilterValue = (typeof QUOTA_FILTER_VALUES)[number] - -export function isQuotaFilterValue(value: string): value is QuotaFilterValue { - return (QUOTA_FILTER_VALUES as readonly string[]).includes(value) -} +import { QUOTA_FILTER_ALL_VALUE, QUOTA_FILTERS } from '../constants' +import { isQuotaFilterValue, type QuotaFilterValue } from '../lib/quota-filter' interface QuotaFilterSelectProps { value: QuotaFilterValue diff --git a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx index ad81ca67..b7179b38 100644 --- a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next' import { useIsAdmin } from '@/hooks/use-admin' import { QUOTA_FILTER_ALL_VALUE } from '../constants' import { buildSearchParams } from '../lib/filter' +import { isQuotaFilterValue, type QuotaFilterValue } from '../lib/quota-filter' import { getDefaultTimeRange } from '../lib/utils' import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types' import { CompactDateTimeRangePicker } from './compact-date-time-range-picker' @@ -32,11 +33,7 @@ import { LogsFilterInput, LogsFilterToolbar, } from './logs-filter-toolbar' -import { - isQuotaFilterValue, - QuotaFilterSelect, - type QuotaFilterValue, -} from './quota-filter-select' +import { QuotaFilterSelect } from './quota-filter-select' const route = getRouteApi('/_authenticated/usage-logs/$section') diff --git a/web/default/src/features/usage-logs/lib/quota-filter.ts b/web/default/src/features/usage-logs/lib/quota-filter.ts new file mode 100644 index 00000000..03a3e325 --- /dev/null +++ b/web/default/src/features/usage-logs/lib/quota-filter.ts @@ -0,0 +1,25 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { QUOTA_FILTER_VALUES } from '../constants' + +export type QuotaFilterValue = (typeof QUOTA_FILTER_VALUES)[number] + +export function isQuotaFilterValue(value: string): value is QuotaFilterValue { + return (QUOTA_FILTER_VALUES as readonly string[]).includes(value) +} diff --git a/web/default/tests/secure-verification-continuation.test.tsx b/web/default/tests/secure-verification-continuation.test.tsx index 69134544..9fb93c57 100644 --- a/web/default/tests/secure-verification-continuation.test.tsx +++ b/web/default/tests/secure-verification-continuation.test.tsx @@ -104,7 +104,7 @@ mock.module('../src/features/auth/secure-verification/api', () => ({ })) mock.module( - '../src/features/auth/secure-verification/components/secure-verification-provider', + '../src/features/auth/secure-verification/hooks/use-secure-verification-gate', () => ({ useSecureVerificationGate: () => ({ withVerification: gateWithVerification, From e22f3f92cebf46020f11ee0c4c153b798e64dd62 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 02:54:26 +0800 Subject: [PATCH 04/23] fix(web): isolate tag drafts and clarify callback boundaries --- .../risk-acknowledgement-dialog.tsx | 12 +- .../dialogs/fetch-models-dialog.tsx | 2 +- .../dialogs/multi-key-manage-dialog.tsx | 4 +- .../tag-batch-edit-dialog.runtime.test.tsx | 135 +++++++ .../dialogs/tag-batch-edit-dialog.tsx | 100 +++--- .../components/users/user-charts.tsx | 1 - .../components/tabs/account-bindings-tab.tsx | 4 +- .../channel-health/index.runtime.test.tsx | 83 +++++ .../smart-ops/channel-health/index.tsx | 2 +- .../system-settings/auth/passkey-section.tsx | 6 +- .../components/settings-page-context.tsx | 7 +- .../settings-submit.runtime.test.tsx | 328 ++++++++++++++++++ .../content/chat-settings-section.tsx | 7 +- .../content/json-toggle-section.tsx | 7 +- .../models/claude-settings-card.tsx | 7 +- 15 files changed, 638 insertions(+), 67 deletions(-) create mode 100644 web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx create mode 100644 web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx create mode 100644 web/default/src/features/system-settings/components/settings-submit.runtime.test.tsx diff --git a/web/default/src/components/risk-acknowledgement-dialog.tsx b/web/default/src/components/risk-acknowledgement-dialog.tsx index 03eaa846..1af96d34 100644 --- a/web/default/src/components/risk-acknowledgement-dialog.tsx +++ b/web/default/src/components/risk-acknowledgement-dialog.tsx @@ -91,14 +91,16 @@ export function RiskAcknowledgementDialog({ NormalizedRequiredTextPart[] >(() => { let inputIndex = 0 - return requiredTextParts.map((part) => { + const parts: NormalizedRequiredTextPart[] = [] + for (const part of requiredTextParts) { if (part.type === 'input') { - const normalizedPart = { ...part, inputIndex } + parts.push({ ...part, inputIndex }) inputIndex += 1 - return normalizedPart + } else { + parts.push(part) } - return part - }) + } + return parts }, [requiredTextParts]) const requiredTextInputCount = useMemo( diff --git a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx index cc6e883a..6d24ae6b 100644 --- a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -145,7 +145,7 @@ export function FetchModelsDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, activeChannel?.id, customFetcher]) - const handleFetchModels = async () => { + async function handleFetchModels() { if (!activeChannel && !customFetcher) return setIsFetching(true) diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index 5e34f458..cbb43e25 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -110,11 +110,11 @@ export function MultiKeyManageDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, currentRow?.id]) - const loadKeyStatus = async ( + async function loadKeyStatus( page: number = currentPage, size: number = pageSize, status: number | null = statusFilter - ) => { + ) { if (!currentRow) return setIsLoading(true) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx new file mode 100644 index 00000000..15cc0069 --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx @@ -0,0 +1,135 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { waitFor, within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const testEnv = createReactTestEnvironment() +await testEnv.setup() +const previousStorage = Object.getOwnPropertyDescriptor( + globalThis, + 'localStorage' +) +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: testEnv.dom.window.localStorage, +}) +const { ChannelsProvider, useChannels } = await import('../channels-provider') +const { TagBatchEditDialog } = await import('./tag-batch-edit-dialog') +after(() => { + testEnv.teardown() + if (previousStorage) + Object.defineProperty(globalThis, 'localStorage', previousStorage) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +test('a late response for a previous tag cannot overwrite the current tag', async () => { + const originalAdapter = api.defaults.adapter + const pending = new Map void>() + const requests: string[] = [] + let groupRequests = 0 + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + if (config.url === '/api/group/') groupRequests += 1 + let data: unknown = { success: true, data: [] } + if (config.url === '/api/channel/tag/models') { + const tag = String(config.params.tag) + requests.push(tag) + const models = await new Promise((resolve) => { + pending.set(tag, resolve) + }) + data = { success: true, data: models } + } + return { data, config, status: 200, statusText: 'OK', headers: {} } + } + function Harness() { + const { setCurrentTag } = useChannels() + const [, setRevision] = useState(0) + return ( + <> + + + + {}} /> + + ) + } + const view = await testEnv.render( + + + + + + ) + const first = within(view.container).getByRole('button', { + name: 'Select first tag', + }) + const second = within(view.container).getByRole('button', { + name: 'Select second tag', + }) + const rerender = within(view.container).getByRole('button', { + name: 'Rerender', + }) + const fields = within(testEnv.dom.window.document.body) + try { + await view.click(first) + await waitFor(() => assert.ok(pending.has('first'))) + await view.click(second) + await waitFor(() => assert.ok(pending.has('second'))) + await act(async () => pending.get('second')!('model-second')) + await waitFor(() => { + assert.equal( + (fields.getByLabelText('Models') as HTMLTextAreaElement).value, + 'model-second' + ) + }) + await act(async () => pending.get('first')!('model-first')) + await waitFor(() => { + assert.equal( + (fields.getByLabelText('Models') as HTMLTextAreaElement).value, + 'model-second' + ) + assert.equal( + (fields.getByLabelText('Tag Name') as HTMLInputElement).value, + 'second' + ) + }) + await view.click(rerender) + await act(async () => { + await testEnv.i18n.changeLanguage('fr') + }) + assert.deepEqual(requests, ['first', 'second']) + assert.equal(groupRequests, 1) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx index a8dc78cc..8d69e2e6 100644 --- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useEffectEvent, useMemo } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2, AlertCircle } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -52,28 +52,45 @@ type TagBatchEditDialogProps = { onOpenChange: (open: boolean) => void } -export function TagBatchEditDialog({ +export function TagBatchEditDialog(props: TagBatchEditDialogProps) { + const { currentTag } = useChannels() + const { data: groupsData, isLoading: isLoadingGroups } = useQuery({ + queryKey: ['groups'], + queryFn: getGroups, + }) + return ( + + ) +} + +function TagBatchEditDialogContent({ open, onOpenChange, -}: TagBatchEditDialogProps) { + currentTag, + groupsData, + isLoadingGroups, +}: TagBatchEditDialogProps & { + currentTag: string | null + groupsData: Awaited> | undefined + isLoadingGroups: boolean +}) { const { t } = useTranslation() - const { currentTag } = useChannels() const queryClient = useQueryClient() - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(Boolean(open && currentTag)) const [isSaving, setIsSaving] = useState(false) // Form fields - const [newTag, setNewTag] = useState('') + const [newTag, setNewTag] = useState(currentTag ?? '') const [models, setModels] = useState('') const [modelMapping, setModelMapping] = useState('') const [groups, setGroups] = useState([]) - // Fetch available groups - const { data: groupsData, isLoading: isLoadingGroups } = useQuery({ - queryKey: ['groups'], - queryFn: getGroups, - }) - // Transform groups to multi-select options const groupOptions = useMemo(() => { if (!groupsData?.data) return [] @@ -84,40 +101,37 @@ export function TagBatchEditDialog({ })) }, [groupsData, groups]) - useEffect(() => { - if (open && currentTag) { - loadTagData() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentTag]) - - const loadTagData = async () => { - if (!currentTag) return - - setIsLoading(true) - try { - // Fetch current tag models - const tagModelsResponse = await getTagModels(currentTag) - if (tagModelsResponse.success && tagModelsResponse.data) { - setModels(tagModelsResponse.data) - } + const reportLoadError = useEffectEvent((error: unknown) => { + toast.error( + error instanceof Error ? error.message : t('Failed to load tag data') + ) + }) - // Fetch all available models (for future use if needed) - const allModelsResponse = await getAllModels() - if (allModelsResponse.success && allModelsResponse.data) { - // Available models could be used for autocomplete in the future + useEffect(() => { + if (!open || !currentTag) return + const tag = currentTag + let cancelled = false + async function loadTagData() { + try { + const tagModelsResponse = await getTagModels(tag) + if (cancelled) return + if (tagModelsResponse.success && tagModelsResponse.data) { + setModels(tagModelsResponse.data) + } + await getAllModels() + } catch (error: unknown) { + if (!cancelled) { + reportLoadError(error) + } + } finally { + if (!cancelled) setIsLoading(false) } - - // Initialize new tag with current tag name - setNewTag(currentTag) - } catch (_error: unknown) { - toast.error( - _error instanceof Error ? _error.message : t('Failed to load tag data') - ) - } finally { - setIsLoading(false) } - } + void loadTagData() + return () => { + cancelled = true + } + }, [open, currentTag]) const handleSave = async () => { if (!currentTag) return diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx index 137a58e6..692c853a 100644 --- a/web/default/src/features/dashboard/components/users/user-charts.tsx +++ b/web/default/src/features/dashboard/components/users/user-charts.tsx @@ -148,7 +148,6 @@ export function UserCharts() { t, topUserLimit, customization.preset, - customization.radius, ] ) diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index d806b4a1..08b31bcc 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -117,7 +117,9 @@ export function AccountBindingsTab({ const handleBindCustomOAuth = (provider: { id: number; name: string }) => { const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true` - window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + window.location.assign( + `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + ) } useEffect(() => { diff --git a/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx new file mode 100644 index 00000000..fcc1200b --- /dev/null +++ b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx @@ -0,0 +1,83 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { waitFor } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const errorKey = 'We could not load the channel health policy.' +const frenchError = 'Impossible de charger la politique des canaux.' +const testEnv = createReactTestEnvironment({ + resources: { + en: { translation: { [errorKey]: errorKey } }, + fr: { translation: { [errorKey]: frenchError } }, + }, +}) +await testEnv.setup() +const { ChannelHealth } = await import('./index') +after(() => testEnv.teardown()) + +test('policy errors translate without changing the data cache or repeating the policy request', async () => { + const originalAdapter = api.defaults.adapter + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + let policyRequests = 0 + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + const policy = config.url === '/api/smart-ops/channel-health/policy' + if (policy) policyRequests += 1 + return { + data: { success: true, data: [] }, + config, + status: 200, + statusText: 'OK', + headers: {}, + } + } + const queryKey = ['smart-ops', 'channel-health-policy'] + const view = await testEnv.render( + + + + ) + try { + await waitFor(() => { + assert.equal(client.getQueryState(queryKey)?.status, 'error') + assert.ok(view.container.textContent?.includes(errorKey)) + }) + const cachedError = client.getQueryState(queryKey)?.error + await act(async () => { + await testEnv.i18n.changeLanguage('fr') + }) + await waitFor(() => { + assert.ok(view.container.textContent?.includes(frenchError)) + }) + assert.equal(client.getQueryState(queryKey)?.error, cachedError) + assert.equal(policyRequests, 1) + assert.equal(client.getQueryCache().findAll({ queryKey }).length, 1) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) diff --git a/web/default/src/features/smart-ops/channel-health/index.tsx b/web/default/src/features/smart-ops/channel-health/index.tsx index 8cab67fe..aad66e57 100644 --- a/web/default/src/features/smart-ops/channel-health/index.tsx +++ b/web/default/src/features/smart-ops/channel-health/index.tsx @@ -397,7 +397,7 @@ export function ChannelHealth(): ReactElement { ) if (!hasAllOptions) { throw new Error( - response.message || t('We could not load the channel health policy.') + response.message || 'We could not load the channel health policy.' ) } return response diff --git a/web/default/src/features/system-settings/auth/passkey-section.tsx b/web/default/src/features/system-settings/auth/passkey-section.tsx index fdd54ccf..63b88c53 100644 --- a/web/default/src/features/system-settings/auth/passkey-section.tsx +++ b/web/default/src/features/system-settings/auth/passkey-section.tsx @@ -187,9 +187,11 @@ export function PasskeySection(props: PasskeySectionProps) { return (
- + void form.handleSubmit(onSubmit)(event)} + > void form.handleSubmit(onSubmit)()} isSaving={updateOption.isPending} /> } -export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { +export function SettingsPageFormActions({ + saveButtonRef, + ...props +}: SettingsPageFormActionsProps) { const { t } = useTranslation() const saveLabel = props.isSaving ? (props.savingLabel ?? 'Saving...') @@ -116,7 +119,7 @@ export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { )} + {}} /> + + ) + } + const view = await testEnv.render( + + + + + + ) + try { + await view.click( + within(view.container).getByRole('button', { name: 'Select ready tag' }) + ) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + const models = within(testEnv.dom.window.document.body).queryByLabelText( + 'Models' + ) as HTMLTextAreaElement | null + assert.equal(models !== null, true) + assert.equal(models?.value, 'tag-only-model') + assert.equal(auxiliaryRequests, 0) + } finally { + await act(async () => releaseAuxiliary()) + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx index 8d69e2e6..50499588 100644 --- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx @@ -36,12 +36,7 @@ import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { MultiSelect } from '@/components/multi-select' -import { - getTagModels, - editTagChannels, - getAllModels, - getGroups, -} from '../../api' +import { getTagModels, editTagChannels, getGroups } from '../../api' import { channelsQueryKeys } from '../../lib' import type { TagOperationParams } from '../../types' import { useChannels } from '../channels-provider' @@ -118,7 +113,6 @@ function TagBatchEditDialogContent({ if (tagModelsResponse.success && tagModelsResponse.data) { setModels(tagModelsResponse.data) } - await getAllModels() } catch (error: unknown) { if (!cancelled) { reportLoadError(error) From ebb811334f4eea2fde23703dc9893a0128c7bfd5 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 12:42:59 +0800 Subject: [PATCH 07/23] fix(web): isolate async reads and complete effect lint cleanup --- .../components/ai-elements/web-preview.tsx | 9 +- .../risk-acknowledgement-dialog.tsx | 22 +- .../features/auth/hooks/use-oauth-login.ts | 17 +- .../features/auth/lib/app-session-verifier.ts | 33 +- .../dialogs/balance-query-dialog.tsx | 86 ++-- .../codex-oauth-dialog.runtime.test.tsx | 119 +++++ .../components/dialogs/codex-oauth-dialog.tsx | 62 ++- .../components/dialogs/edit-tag-dialog.tsx | 37 +- .../dialogs/multi-key-manage-dialog.tsx | 125 ++--- .../read-dialog-lifecycle.runtime.test.tsx | 234 ++++++++++ .../drawers/channel-mutate-drawer.tsx | 1 + .../models/consumption-distribution-chart.tsx | 6 +- .../components/models/model-charts.tsx | 6 +- .../dialogs/extend-deployment-dialog.tsx | 9 +- .../dialogs/rename-deployment-dialog.tsx | 12 +- .../sync-wizard-dialog.runtime.test.tsx | 78 ++++ .../components/dialogs/sync-wizard-dialog.tsx | 36 +- .../components/checkin-calendar-card.tsx | 9 +- .../components/language-preferences-card.tsx | 12 +- .../components/sidebar-modules-card.tsx | 49 +- .../components/tabs/account-bindings-tab.tsx | 31 +- .../components/tabs/notification-tab.tsx | 77 ++-- .../src/features/profile/hooks/use-profile.ts | 78 ++-- .../src/features/profile/hooks/use-two-fa.ts | 63 ++- .../dialogs/subscription-purchase-dialog.tsx | 20 +- .../content/announcements-section.tsx | 39 +- .../content/api-info-section.tsx | 36 +- .../system-settings/content/faq-section.tsx | 36 +- .../content/uptime-kuma-section.tsx | 36 +- .../general/channel-affinity/index.tsx | 108 ++--- .../models/upstream-ratio-sync.tsx | 30 +- .../user-info-dialog-race.runtime.test.tsx | 84 ++++ .../components/dialogs/user-info-dialog.tsx | 58 +-- .../dialogs/user-binding-dialog.tsx | 123 +++-- .../wallet/components/recharge-form-card.tsx | 11 +- .../components/subscription-plans-card.tsx | 84 ++-- .../features/wallet/hooks/use-affiliate.ts | 66 ++- .../features/wallet/hooks/use-topup-info.ts | 125 ++--- web/default/src/features/wallet/index.tsx | 84 ++-- .../wallet/wallet-lifecycle.runtime.test.tsx | 330 ++++++++++++++ .../hooks/state-lifecycle.runtime.test.tsx | 428 ++++++++++++++++++ .../src/hooks/use-minimum-loading-time.ts | 50 +- web/default/src/hooks/use-mobile.ts | 28 +- web/default/src/hooks/use-mobile.tsx | 28 +- web/default/src/hooks/use-read-scope.ts | 60 +++ web/default/src/hooks/use-resettable-state.ts | 66 +++ .../src/lib/api-session.runtime.test.ts | 285 ++++++++++++ web/default/src/lib/api.ts | 36 +- web/default/src/lib/auth-errors.ts | 35 ++ web/default/src/lib/theme-radius.ts | 52 ++- web/default/src/main.tsx | 30 +- web/default/src/stores/auth-store.ts | 21 +- web/default/src/test/react.test.ts | 30 ++ web/default/src/test/react.tsx | 2 +- 54 files changed, 2837 insertions(+), 795 deletions(-) create mode 100644 web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx create mode 100644 web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx create mode 100644 web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx create mode 100644 web/default/src/features/usage-logs/components/dialogs/user-info-dialog-race.runtime.test.tsx create mode 100644 web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx create mode 100644 web/default/src/hooks/state-lifecycle.runtime.test.tsx create mode 100644 web/default/src/hooks/use-read-scope.ts create mode 100644 web/default/src/hooks/use-resettable-state.ts create mode 100644 web/default/src/lib/api-session.runtime.test.ts create mode 100644 web/default/src/lib/auth-errors.ts diff --git a/web/default/src/components/ai-elements/web-preview.tsx b/web/default/src/components/ai-elements/web-preview.tsx index 49558b37..7ed95ce9 100644 --- a/web/default/src/components/ai-elements/web-preview.tsx +++ b/web/default/src/components/ai-elements/web-preview.tsx @@ -23,13 +23,13 @@ import { createContext, type ReactNode, useContext, - useEffect, useState, } from 'react' import { ChevronDownIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import dayjs from '@/lib/dayjs' import { cn } from '@/lib/utils' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Collapsible, @@ -162,12 +162,7 @@ export const WebPreviewUrl = ({ }: WebPreviewUrlProps) => { const { t } = useTranslation() const { url, setUrl } = useWebPreview() - const [inputValue, setInputValue] = useState(url) - - // Sync input value with context URL when it changes externally - useEffect(() => { - setInputValue(url) - }, [url]) + const [inputValue, setInputValue] = useResettableState(url, url) const handleChange = (event: React.ChangeEvent) => { setInputValue(event.target.value) diff --git a/web/default/src/components/risk-acknowledgement-dialog.tsx b/web/default/src/components/risk-acknowledgement-dialog.tsx index 1af96d34..b34fa241 100644 --- a/web/default/src/components/risk-acknowledgement-dialog.tsx +++ b/web/default/src/components/risk-acknowledgement-dialog.tsx @@ -16,9 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' +import { useResettableState } from '@/hooks/use-resettable-state' import { AlertDialog, AlertDialogCancel, @@ -83,9 +84,6 @@ export function RiskAcknowledgementDialog({ className, }: RiskAcknowledgementDialogProps) { const { t } = useTranslation() - const [checkedItems, setCheckedItems] = useState([]) - const [typedText, setTypedText] = useState('') - const [typedTextParts, setTypedTextParts] = useState([]) const normalizedRequiredTextParts = useMemo< NormalizedRequiredTextPart[] @@ -114,12 +112,16 @@ export function RiskAcknowledgementDialog({ ? normalizedRequiredTextParts.map((part) => part.text).join('') : requiredText - useEffect(() => { - if (!open) return - setCheckedItems(Array(checklist.length).fill(false)) - setTypedText('') - setTypedTextParts(Array(requiredTextInputCount).fill('')) - }, [open, checklist.length, requiredTextInputCount]) + const draftKey = `${open}:${checklist.length}:${requiredTextInputCount}` + const [checkedItems, setCheckedItems] = useResettableState( + () => Array(checklist.length).fill(false), + draftKey + ) + const [typedText, setTypedText] = useResettableState('', draftKey) + const [typedTextParts, setTypedTextParts] = useResettableState( + () => Array(requiredTextInputCount).fill(''), + draftKey + ) const allChecked = useMemo(() => { if (checklist.length === 0) return true diff --git a/web/default/src/features/auth/hooks/use-oauth-login.ts b/web/default/src/features/auth/hooks/use-oauth-login.ts index 77d3b44d..6c7944df 100644 --- a/web/default/src/features/auth/hooks/use-oauth-login.ts +++ b/web/default/src/features/auth/hooks/use-oauth-login.ts @@ -41,20 +41,21 @@ type LogoutRequestConfig = AxiosRequestConfig & { export function useOAuthLogin(status: SystemStatus | null) { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(false) - const [githubButtonText, setGithubButtonText] = useState('') + const [githubButtonMessage, setGithubButtonText] = useState( + 'Continue with GitHub' + ) + const githubButtonText = t(githubButtonMessage) const [githubButtonDisabled, setGithubButtonDisabled] = useState(false) const githubTimeoutRef = useRef(null) const { auth } = useAuthStore() useEffect(() => { - setGithubButtonText(t('Continue with GitHub')) - return () => { if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } } - }, [t]) + }, []) const resetSession = async () => { try { @@ -77,7 +78,7 @@ export function useOAuthLogin(status: SystemStatus | null) { setIsLoading(true) setGithubButtonDisabled(true) - setGithubButtonText(t('Redirecting to GitHub...')) + setGithubButtonText('Redirecting to GitHub...') if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) @@ -86,7 +87,7 @@ export function useOAuthLogin(status: SystemStatus | null) { githubTimeoutRef.current = setTimeout(() => { setIsLoading(false) setGithubButtonText( - t('Request timed out, please refresh and restart GitHub login') + 'Request timed out, please refresh and restart GitHub login' ) setGithubButtonDisabled(true) }, 20000) @@ -100,7 +101,7 @@ export function useOAuthLogin(status: SystemStatus | null) { clearTimeout(githubTimeoutRef.current) } setIsLoading(false) - setGithubButtonText(t('Continue with GitHub')) + setGithubButtonText('Continue with GitHub') setGithubButtonDisabled(false) return } @@ -113,7 +114,7 @@ export function useOAuthLogin(status: SystemStatus | null) { clearTimeout(githubTimeoutRef.current) } setIsLoading(false) - setGithubButtonText(t('Continue with GitHub')) + setGithubButtonText('Continue with GitHub') setGithubButtonDisabled(false) } } diff --git a/web/default/src/features/auth/lib/app-session-verifier.ts b/web/default/src/features/auth/lib/app-session-verifier.ts index d95b1430..eef9ac9f 100644 --- a/web/default/src/features/auth/lib/app-session-verifier.ts +++ b/web/default/src/features/auth/lib/app-session-verifier.ts @@ -18,12 +18,41 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' +import { isStaleAuthResponse } from '@/lib/auth-errors' import { createSessionVerifier } from './session-verifier' +async function getSelfForVerification() { + while (true) { + const starting = useAuthStore.getState() + try { + const response = await getSelf() + const current = useAuthStore.getState() + // Authenticated identities include the revision below. When both are + // anonymous, the verifier cannot see a reset, so refresh this pure read. + if ( + current.sessionRevision === starting.sessionRevision || + starting.auth.user || + current.auth.user + ) + return response + } catch (error) { + const current = useAuthStore.getState() + if ( + isStaleAuthResponse(error) && + !starting.auth.user && + !current.auth.user + ) + continue + throw error + } + } +} + export const appSessionVerifier = createSessionVerifier({ getAuthState: () => useAuthStore.getState().auth, - getSelf, - getUserIdentity: (user) => user.id, + getSelf: getSelfForVerification, + getUserIdentity: (user) => + `${useAuthStore.getState().sessionRevision}:${user.id}`, isUnauthorized: (error: unknown) => (error as { response?: { status?: number } })?.response?.status === 401, }) diff --git a/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx index dda0ae0c..5edc4489 100644 --- a/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -16,13 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useEffectEvent } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw, DollarSign } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { formatCurrencyFromUSD } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -52,48 +54,67 @@ export function BalanceQueryDialog({ const { t } = useTranslation() const { currentRow, setCurrentRow } = useChannels() const queryClient = useQueryClient() - const [isQuerying, setIsQuerying] = useState(false) - const [balance, setBalance] = useState(null) - const [balanceUpdatedTime, setBalanceUpdatedTime] = useState( - null + const channelId = currentRow?.id + const isCodex = currentRow?.type === 57 + const beginRead = useReadScope(JSON.stringify([open, channelId])) + const [isQuerying, setIsQuerying] = useResettableState( + Boolean(open && isCodex && channelId), + beginRead + ) + const [balance, setBalance] = useResettableState( + null, + beginRead ) + const [balanceUpdatedTime, setBalanceUpdatedTime] = useResettableState< + number | null + >(null, beginRead) const [codexUsageResponse, setCodexUsageResponse] = - useState(null) - - const isCodex = currentRow?.type === 57 - - const handleQueryCodexUsage = async () => { - const row = currentRow - if (!row) return + useResettableState(null, beginRead) + const loadCodexUsage = useCallback(() => { + if (!channelId) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return getCodexUsage(channelId) + .then((res) => { + if (!current()) return + if (!res.success) + throw new Error(res.message || t('Failed to fetch usage')) + setCodexUsageResponse(res) + }) + .catch((error: unknown) => { + if (current()) + toast.error( + error instanceof Error ? error.message : t('Failed to fetch usage') + ) + }) + .finally(() => { + if (current()) setIsQuerying(false) + }) + }, [channelId, beginRead, setCodexUsageResponse, setIsQuerying, t]) + const handleQueryCodexUsage = () => { setIsQuerying(true) - try { - const res = await getCodexUsage(row.id) - if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) - } - setCodexUsageResponse(res) - } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch usage') - ) - } finally { - setIsQuerying(false) - } + return loadCodexUsage() } - + const readInitialUsage = useEffectEvent(() => loadCodexUsage()) useEffect(() => { - if (!isCodex) return - if (!open) return - handleQueryCodexUsage() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, isCodex]) + if (open && isCodex) void readInitialUsage() + }, [open, isCodex, channelId, beginRead]) if (!currentRow) return null const handleQueryBalance = async () => { + const current = beginRead() + if (!current()) return setIsQuerying(true) try { const response = await updateChannelBalance(currentRow.id) + if (!current()) { + if (response.success && response.balance !== undefined) + void queryClient.invalidateQueries({ + queryKey: channelsQueryKeys.lists(), + }) + return + } if (response.success && response.balance !== undefined) { const newBalance = response.balance const now = Math.floor(Date.now() / 1000) @@ -117,11 +138,12 @@ export function BalanceQueryDialog({ toast.error(response.message || t('Failed to query balance')) } } catch (error: unknown) { + if (!current()) return toast.error( error instanceof Error ? error.message : t('Failed to query balance') ) } finally { - setIsQuerying(false) + if (current()) setIsQuerying(false) } } diff --git a/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx new file mode 100644 index 00000000..a3b5c79a --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx @@ -0,0 +1,119 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const env = createReactTestEnvironment() +await env.setup() +const { fireEvent, within } = await import('@testing-library/react/pure') +const { CodexOAuthDialog } = await import('./codex-oauth-dialog') +after(() => env.teardown()) + +for (const transition of ['editor', 'reopen'] as const) + test('late OAuth completion is ignored after ' + transition, async () => { + const adapter = api.defaults.adapter + let release!: () => void, started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + const assigned: string[] = [] + const requests: string[] = [] + api.defaults.adapter = async (config) => { + requests.push(config.url || '') + assert.equal(config.url, '/api/channel/codex/oauth/complete') + started() + await new Promise((resolve) => { + release = resolve + }) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { key: 'synthetic-credential-for-editor-one' }, + }, + } + } + function Harness() { + const [editor, setEditor] = useState('one') + const [open, setOpen] = useState(true) + return ( + <> + + {}} + onKeyGenerated={(key) => assigned.push(key)} + /> + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + const input = body.getByPlaceholderText( + 'Paste the full callback URL (includes code & state)' + ) as HTMLInputElement + await act(async () => { + fireEvent.change(input, { + target: { + value: 'http://localhost/callback?code=synthetic&state=synthetic', + }, + }) + }) + const generate = body.getByRole('button', { + name: 'Generate credential', + }) as HTMLButtonElement + assert.equal(generate.disabled, false) + await view.click(generate) + await ready + await view.click(within(view.container).getByText('Change editor')) + if (transition === 'reopen') + await view.click(within(view.container).getByText('Change editor')) + await act(async () => release()) + assert.deepEqual(assigned, []) + assert.equal( + ( + body.getByPlaceholderText( + 'Paste the full callback URL (includes code & state)' + ) as HTMLInputElement + ).value, + '' + ) + assert.deepEqual(requests, ['/api/channel/codex/oauth/complete']) + } finally { + release?.() + await view.unmount() + api.defaults.adapter = adapter + } + }) diff --git a/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx index d1ea7225..a5558c6e 100644 --- a/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx @@ -16,12 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useLayoutEffect, useMemo, useRef } from 'react' import { ExternalLink, Copy, Check, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' import { tryPrettyJson } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { useResettableState } from '@/hooks/use-resettable-state' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -39,33 +41,36 @@ type CodexOAuthDialogProps = { open: boolean onOpenChange: (open: boolean) => void onKeyGenerated: (key: string) => void + scopeKey?: string } export function CodexOAuthDialog({ open, onOpenChange, onKeyGenerated, + scopeKey, }: CodexOAuthDialogProps) { const { t } = useTranslation() const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false }) - const [state, setState] = useState({ - authorizeUrl: '', - callbackUrl: '', - isStarting: false, - isCompleting: false, - }) - - useEffect(() => { - if (!open) { - setState({ - authorizeUrl: '', - callbackUrl: '', - isStarting: false, - isCompleting: false, - }) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const draftKey = JSON.stringify([open, scopeKey, sessionRevision]) + const lifecycle = useRef(0) + useLayoutEffect(() => { + lifecycle.current += 1 + return () => { + lifecycle.current += 1 } - }, [open]) + }, [draftKey]) + const [state, setState] = useResettableState( + () => ({ + authorizeUrl: '', + callbackUrl: '', + isStarting: false, + isCompleting: false, + }), + draftKey + ) const canCopyAuthorizeUrl = Boolean(state.authorizeUrl && !state.isStarting) const canComplete = useMemo( @@ -74,9 +79,15 @@ export function CodexOAuthDialog({ ) const handleStart = async () => { + if (!open) return + const generation = lifecycle.current + const current = () => + lifecycle.current === generation && + useAuthStore.getState().sessionRevision === sessionRevision setState((prev) => ({ ...prev, isStarting: true })) try { const res = await startCodexOAuth() + if (!current()) return if (!res.success) { throw new Error(res.message || 'Failed to start OAuth') } @@ -90,25 +101,31 @@ export function CodexOAuthDialog({ try { window.open(url, '_blank', 'noopener,noreferrer') toast.success(t('Opened authorization page')) - } catch (error) { + } catch { // eslint-disable-next-line no-console - console.warn('Failed to open authorization page:', error) + console.warn('Failed to open authorization page') toast.warning(t('Please manually copy and open the authorization link')) } } catch (error) { + if (!current()) return toast.error( error instanceof Error ? error.message : t('OAuth start failed') ) } finally { - setState((prev) => ({ ...prev, isStarting: false })) + if (current()) setState((prev) => ({ ...prev, isStarting: false })) } } const handleComplete = async () => { - if (!state.callbackUrl.trim()) return + if (!open || !state.callbackUrl.trim()) return + const generation = lifecycle.current + const current = () => + lifecycle.current === generation && + useAuthStore.getState().sessionRevision === sessionRevision setState((prev) => ({ ...prev, isCompleting: true })) try { const res = await completeCodexOAuth(state.callbackUrl.trim()) + if (!current()) return if (!res.success) { throw new Error(res.message || 'OAuth failed') } @@ -122,9 +139,10 @@ export function CodexOAuthDialog({ toast.success(t('Credential generated')) onOpenChange(false) } catch (error) { + if (!current()) return toast.error(error instanceof Error ? error.message : t('OAuth failed')) } finally { - setState((prev) => ({ ...prev, isCompleting: false })) + if (current()) setState((prev) => ({ ...prev, isCompleting: false })) } } diff --git a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx index b73c8e8c..2c467613 100644 --- a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -66,11 +67,14 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { const queryClient = useQueryClient() // Form state - const [newTag, setNewTag] = useState('') - const [selectedModels, setSelectedModels] = useState([]) - const [customModel, setCustomModel] = useState('') - const [modelMapping, setModelMapping] = useState('') - const [selectedGroups, setSelectedGroups] = useState([]) + const draftKey = JSON.stringify([open, currentTag]) + const [newTag, setNewTag] = useResettableState(currentTag || '', draftKey) + const [customModel, setCustomModel] = useResettableState('', draftKey) + const [modelMapping, setModelMapping] = useResettableState('', draftKey) + const [selectedGroups, setSelectedGroups] = useResettableState( + [], + draftKey + ) const [isSubmitting, setIsSubmitting] = useState(false) // Fetch tag models @@ -98,23 +102,10 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { allModelsData?.data?.map((m) => m.id).filter(Boolean) || [] const availableGroups = groupsData?.data || [] - // Initialize form when tag changes - useEffect(() => { - if (open && currentTag) { - setNewTag(currentTag) - setModelMapping('') - setSelectedGroups([]) - setCustomModel('') - - // Load tag models - if (tagModelsData?.data) { - const models = tagModelsData.data.split(',').filter(Boolean) - setSelectedModels(models) - } else { - setSelectedModels([]) - } - } - }, [open, currentTag, tagModelsData]) + const [selectedModels, setSelectedModels] = useResettableState( + () => tagModelsData?.data?.split(',').filter(Boolean) || [], + JSON.stringify([open, currentTag, tagModelsData?.data]) + ) const handleAddCustomModel = () => { if (!customModel.trim()) return diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index cbb43e25..2d7e4e59 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -16,11 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' +import { useEffect, useEffectEvent, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -83,68 +85,85 @@ export function MultiKeyManageDialog({ const { currentRow } = useChannels() const queryClient = useQueryClient() + const channelId = currentRow?.id + const beginRead = useReadScope(JSON.stringify([open, channelId])) + // Data state - const [isLoading, setIsLoading] = useState(false) - const [keys, setKeys] = useState([]) - const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useResettableState( + Boolean(open && channelId), + beginRead + ) + const [keys, setKeys] = useResettableState([], beginRead) + const [currentPage, setCurrentPage] = useResettableState(1, beginRead) const [pageSize, setPageSize] = useState(10) - const [total, setTotal] = useState(0) - const [totalPages, setTotalPages] = useState(0) - const [enabledCount, setEnabledCount] = useState(0) - const [manualDisabledCount, setManualDisabledCount] = useState(0) - const [autoDisabledCount, setAutoDisabledCount] = useState(0) + const [total, setTotal] = useResettableState(0, beginRead) + const [totalPages, setTotalPages] = useResettableState(0, beginRead) + const [enabledCount, setEnabledCount] = useResettableState(0, beginRead) + const [manualDisabledCount, setManualDisabledCount] = useResettableState( + 0, + beginRead + ) + const [autoDisabledCount, setAutoDisabledCount] = useResettableState( + 0, + beginRead + ) // UI state - const [statusFilter, setStatusFilter] = useState(null) + const [statusFilter, setStatusFilter] = useResettableState( + null, + beginRead + ) const [confirmAction, setConfirmAction] = - useState(null) + useResettableState(null, beginRead) const [isPerformingAction, setIsPerformingAction] = useState(false) - // Reset and load data when dialog opens + const readInitialKeys = useEffectEvent(() => readKeyStatus(1, pageSize, null)) useEffect(() => { - if (open && currentRow) { - setCurrentPage(1) - setStatusFilter(null) - loadKeyStatus(1, pageSize, null) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentRow?.id]) - - async function loadKeyStatus( - page: number = currentPage, - size: number = pageSize, - status: number | null = statusFilter + if (open && channelId) void readInitialKeys() + }, [open, channelId, beginRead]) + + function readKeyStatus(page: number, size: number, status: number | null) { + if (!channelId) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return getMultiKeyStatus( + channelId, + page, + size, + status === null ? undefined : status + ) + .then((response) => { + if (!current()) return + if (response.success && response.data) { + setKeys(response.data.keys || []) + setTotal(response.data.total || 0) + setCurrentPage(response.data.page || 1) + setPageSize(response.data.page_size || 10) + setTotalPages(response.data.total_pages || 0) + setEnabledCount(response.data.enabled_count || 0) + setManualDisabledCount(response.data.manual_disabled_count || 0) + setAutoDisabledCount(response.data.auto_disabled_count || 0) + } else toast.error(response.message || t('Failed to load key status')) + }) + .catch((error: unknown) => { + if (current()) + toast.error( + error instanceof Error + ? error.message + : t('Failed to load key status') + ) + }) + .finally(() => { + if (current()) setIsLoading(false) + }) + } + function loadKeyStatus( + page = currentPage, + size = pageSize, + status = statusFilter ) { - if (!currentRow) return - setIsLoading(true) - try { - const response = await getMultiKeyStatus( - currentRow.id, - page, - size, - status === null ? undefined : status - ) - - if (response.success && response.data) { - setKeys(response.data.keys || []) - setTotal(response.data.total || 0) - setCurrentPage(response.data.page || 1) - setPageSize(response.data.page_size || 10) - setTotalPages(response.data.total_pages || 0) - setEnabledCount(response.data.enabled_count || 0) - setManualDisabledCount(response.data.manual_disabled_count || 0) - setAutoDisabledCount(response.data.auto_disabled_count || 0) - } else { - toast.error(response.message || t('Failed to load key status')) - } - } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to load key status') - ) - } finally { - setIsLoading(false) - } + return readKeyStatus(page, size, status) } const handleStatusFilterChange = (value: string) => { diff --git a/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx new file mode 100644 index 00000000..3ddb7ef0 --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx @@ -0,0 +1,234 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' +import { channelSchema } from '../../types' + +const env = createReactTestEnvironment() +await env.setup() +Object.defineProperty(env.dom.window.Element.prototype, 'getAnimations', { + configurable: true, + value: () => [], +}) +const storage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: env.dom.window.localStorage, +}) +const { within, waitFor } = await import('@testing-library/react/pure') +const { ChannelsProvider, useChannels } = await import('../channels-provider') +const { MultiKeyManageDialog } = await import('./multi-key-manage-dialog') +const { BalanceQueryDialog } = await import('./balance-query-dialog') +const { UserBindingDialog } = + await import('@/features/users/components/dialogs/user-binding-dialog') +after(() => { + env.teardown() + if (storage) Object.defineProperty(globalThis, 'localStorage', storage) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +function ChannelHarness({ mode }: { mode: 'keys' | 'usage' }) { + const { setCurrentRow } = useChannels() + const select = (id: number) => + setCurrentRow( + channelSchema.parse({ + id, + type: mode === 'keys' ? 1 : 57, + key: '', + status: 1, + name: `Synthetic channel ${id}`, + created_time: 0, + test_time: 0, + response_time: 0, + balance_updated_time: 0, + }) + ) + return ( + <> + + + {mode === 'keys' ? ( + {}} /> + ) : ( + {}} /> + )} + + ) +} + +test('late multi-key results cannot replace the current channel keys', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + const requests: number[] = [] + api.defaults.adapter = async (config) => { + assert.equal(config.url, '/api/channel/multi_key/manage') + const params = JSON.parse(config.data) + assert.equal(params.action, 'get_key_status') + const id = Number(params.channel_id) + requests.push(id) + await new Promise((resolve) => pending.set(id, resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { + keys: [{ index: 0, status: 2, reason: `synthetic-reason-${id}` }], + total: 1, + page: 1, + page_size: 10, + total_pages: 1, + enabled_count: 0, + manual_disabled_count: 1, + auto_disabled_count: 0, + }, + }, + } + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body) + try { + await view.click(within(view.container).getByText('First channel')) + await view.click(within(view.container).getByText('Second channel')) + assert.ok(pending.has(2)) + await act(async () => pending.get(2)!()) + assert.ok(body.queryByText('synthetic-reason-2')) + await act(async () => pending.get(1)!()) + assert.ok(body.queryByText('synthetic-reason-2')) + assert.equal(body.queryByText('synthetic-reason-1'), null) + assert.deepEqual(requests, [1, 2]) + } finally { + pending.forEach((resolve) => resolve()) + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('Codex usage loads the new channel even when its type stays the same', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + api.defaults.adapter = async (config) => { + const id = Number(config.url?.match(/channel\/(\d+)\/codex\/usage/)?.[1]) + assert.ok(id === 1 || id === 2) + await new Promise((resolve) => pending.set(id, resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { email: `codex-${id}@example.invalid`, plan_type: 'pro' }, + }, + } + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body) + try { + await view.click(within(view.container).getByText('First channel')) + await view.click(within(view.container).getByText('Second channel')) + assert.ok(pending.has(2), 'changing channel ID must load its usage') + await act(async () => pending.get(2)!()) + assert.ok(body.queryByText('codex-2@example.invalid')) + await act(async () => pending.get(1)!()) + assert.ok(body.queryByText('codex-2@example.invalid')) + assert.equal(body.queryByText('codex-1@example.invalid'), null) + } finally { + pending.forEach((resolve) => resolve()) + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('late binding details cannot replace another selected user', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + let data: unknown = [] + const match = config.url?.match(/^\/api\/user\/(\d+)$/) + if (match) { + const id = Number(match[1]) + await new Promise((resolve) => pending.set(id, resolve)) + data = { + id, + username: `synthetic-user-${id}`, + email: `binding-${id}@example.invalid`, + } + } else if (config.url === '/api/status') data = { email_verification: true } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data }, + } + } + function Harness() { + const [id, setId] = useState(1) + return ( + <> + + {}} /> + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + await waitFor(() => assert.ok(pending.has(1))) + await view.click(within(view.container).getByText('Another binding user')) + assert.ok(pending.has(2)) + await act(async () => pending.get(2)!()) + assert.ok(body.queryByText('binding-2@example.invalid')) + await act(async () => pending.get(1)!()) + assert.ok(body.queryByText('binding-2@example.invalid')) + assert.equal(body.queryByText('binding-1@example.invalid'), null) + } finally { + pending.forEach((resolve) => resolve()) + await view.unmount() + api.defaults.adapter = adapter + } +}) diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 7e3ca5a5..47413587 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -2332,6 +2332,7 @@ export function ChannelMutateDrawer({ )} { diff --git a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx index cc509187..de0cb4bb 100644 --- a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx +++ b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx @@ -73,9 +73,11 @@ export function ConsumptionDistributionChart( >(null) const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY - useEffect(() => { + const [previousDefault, setPreviousDefault] = useState(props.defaultChartType) + if (previousDefault !== props.defaultChartType) { + setPreviousDefault(props.defaultChartType) if (props.defaultChartType) setChartType(props.defaultChartType) - }, [props.defaultChartType]) + } useEffect(() => { const updateTheme = async () => { diff --git a/web/default/src/features/dashboard/components/models/model-charts.tsx b/web/default/src/features/dashboard/components/models/model-charts.tsx index f363eca6..a778ae31 100644 --- a/web/default/src/features/dashboard/components/models/model-charts.tsx +++ b/web/default/src/features/dashboard/components/models/model-charts.tsx @@ -71,9 +71,11 @@ export function ModelCharts(props: ModelChartsProps) { >(null) const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY - useEffect(() => { + const [previousDefault, setPreviousDefault] = useState(props.defaultChartTab) + if (previousDefault !== props.defaultChartTab) { + setPreviousDefault(props.defaultChartTab) if (props.defaultChartTab) setActiveTab(props.defaultChartTab) - }, [props.defaultChartTab]) + } useEffect(() => { const updateTheme = async () => { diff --git a/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx b/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx index dfac6469..b73e3c79 100644 --- a/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -50,13 +51,9 @@ export function ExtendDeploymentDialog({ }) { const { t } = useTranslation() const queryClient = useQueryClient() - const [hours, setHours] = useState(1) + const [hours, setHours] = useResettableState(1, `${open}:${deploymentId}`) const [isSubmitting, setIsSubmitting] = useState(false) - useEffect(() => { - if (open) setHours(1) - }, [open]) - const { data: detailsRes, isLoading: isLoadingDetails } = useQuery({ queryKey: ['deployment-details-for-extend', deploymentId], queryFn: () => (deploymentId ? getDeployment(deploymentId) : null), diff --git a/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx b/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx index 99ae43d4..9f26c495 100644 --- a/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -46,13 +47,12 @@ export function RenameDeploymentDialog({ }) { const { t } = useTranslation() const queryClient = useQueryClient() - const [name, setName] = useState(currentName || '') + const [name, setName] = useResettableState( + currentName || '', + JSON.stringify([open, deploymentId, currentName]) + ) const [isSubmitting, setIsSubmitting] = useState(false) - useEffect(() => { - if (open) setName(currentName || '') - }, [open, currentName]) - const trimmed = name.trim() const { data: checkRes, isFetching: isChecking } = useQuery({ diff --git a/web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx new file mode 100644 index 00000000..1392c81f --- /dev/null +++ b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx @@ -0,0 +1,78 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' + +const env = createReactTestEnvironment() +await env.setup() +const { ModelsProvider } = await import('../models-provider') +const { SyncWizardDialog } = await import('./sync-wizard-dialog') +after(() => env.teardown()) + +test('sync language draft survives unrelated renders and resets on reopen', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + function Harness() { + const [open, setOpen] = useState(true) + const [, setRevision] = useState(0) + return ( + <> + + + + + ) + } + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body) + const selected = (locale: string) => + body + .getByRole('radio', { name: locale === 'en' ? 'English' : 'Chinese' }) + .getAttribute('aria-checked') + try { + await view.click(body.getByRole('radio', { name: 'English' })) + assert.equal(selected('en'), 'true') + await view.click(within(view.container).getByText('Rerender host')) + await act(async () => { + await env.i18n.changeLanguage('fr') + }) + assert.equal(selected('en'), 'true') + await view.click(within(view.container).getByText('Toggle wizard')) + await view.click(within(view.container).getByText('Toggle wizard')) + assert.equal(selected('zh'), 'true') + } finally { + await view.unmount() + client.clear() + } +}) diff --git a/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx index f365f743..800c4d2d 100644 --- a/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx @@ -16,13 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { useIsMobile } from '@/hooks/use-mobile' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -59,27 +60,30 @@ export function SyncWizardDialog({ syncWizardOptions, } = useModels() const isMobile = useIsMobile() - const [locale, setLocale] = useState('zh') - const [source, setSource] = useState('official') const [isSyncing, setIsSyncing] = useState(false) // Get translated options const SYNC_SOURCE_OPTIONS = getSyncSourceOptions(t) const SYNC_LOCALE_OPTIONS = getSyncLocaleOptions(t) - useEffect(() => { - if (open) { - setLocale(syncWizardOptions.locale || 'zh') - const preferredSource = SYNC_SOURCE_OPTIONS.find( - (option) => option.value === syncWizardOptions.source - ) - setSource( - preferredSource && !preferredSource.disabled - ? (preferredSource.value as SyncSource) - : 'official' - ) - } - }, [open, syncWizardOptions, SYNC_SOURCE_OPTIONS]) + const draftKey = JSON.stringify([ + open, + syncWizardOptions.locale, + syncWizardOptions.source, + ]) + const [locale, setLocale] = useResettableState( + syncWizardOptions.locale || 'zh', + draftKey + ) + const preferredSource = SYNC_SOURCE_OPTIONS.find( + (option) => option.value === syncWizardOptions.source + ) + const [source, setSource] = useResettableState( + preferredSource && !preferredSource.disabled + ? preferredSource.value + : 'official', + draftKey + ) const handleSync = async () => { setIsSyncing(true) diff --git a/web/default/src/features/profile/components/checkin-calendar-card.tsx b/web/default/src/features/profile/components/checkin-calendar-card.tsx index 071d5750..48e6f74a 100644 --- a/web/default/src/features/profile/components/checkin-calendar-card.tsx +++ b/web/default/src/features/profile/components/checkin-calendar-card.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { CalendarDays, @@ -122,13 +122,10 @@ export function CheckinCalendarCard({ const checkedToday = checkinData?.stats?.checked_in_today === true const todayAward = checkinRecordsMap[todayString] - useEffect(() => { - if (initialLoaded) return - if (isLoading) return - if (!checkinData) return + if (!initialLoaded && !isLoading && checkinData) { setCollapsed(checkedToday) setInitialLoaded(true) - }, [checkinData, checkedToday, initialLoaded, isLoading]) + } const shouldTriggerTurnstile = useCallback( (message?: string) => { diff --git a/web/default/src/features/profile/components/language-preferences-card.tsx b/web/default/src/features/profile/components/language-preferences-card.tsx index 7807ef47..4bdf1284 100644 --- a/web/default/src/features/profile/components/language-preferences-card.tsx +++ b/web/default/src/features/profile/components/language-preferences-card.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { INTERFACE_LANGUAGE_OPTIONS, normalizeInterfaceLanguage, @@ -25,6 +25,7 @@ import { Languages, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' +import { useResettableState } from '@/hooks/use-resettable-state' import { Select, SelectContent, @@ -53,11 +54,10 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) { return normalizeInterfaceLanguage(settings.language || i18n.language) }, [props.profile?.setting, i18n.language]) - const [currentLanguage, setCurrentLanguage] = useState(savedLanguage) - - useEffect(() => { - setCurrentLanguage(savedLanguage) - }, [savedLanguage]) + const [currentLanguage, setCurrentLanguage] = useResettableState( + savedLanguage, + savedLanguage + ) const handleLanguageChange = async (language: string | null) => { if (!language) return diff --git a/web/default/src/features/profile/components/sidebar-modules-card.tsx b/web/default/src/features/profile/components/sidebar-modules-card.tsx index 736037e4..8c9e1e40 100644 --- a/web/default/src/features/profile/components/sidebar-modules-card.tsx +++ b/web/default/src/features/profile/components/sidebar-modules-card.tsx @@ -22,6 +22,8 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Card, @@ -49,9 +51,13 @@ type SectionDef = { export function SidebarModulesCard() { const { t } = useTranslation() const [loading, setLoading] = useState(false) - const [config, setConfig] = useState({}) const currentUser = useAuthStore((s) => s.auth.user) const setUser = useAuthStore((s) => s.auth.setUser) + const beginRead = useReadScope(currentUser?.id) + const [config, setConfig] = useResettableState( + {}, + currentUser?.id + ) const sectionDefs: SectionDef[] = [ { @@ -122,26 +128,31 @@ export function SidebarModulesCard() { }, ] - const loadConfig = useCallback(async () => { - try { - const res = await api.get('/api/user/self') - if (res.data.success && res.data.data?.sidebar_modules) { - const raw = res.data.data.sidebar_modules - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw - setConfig(parsed) - } else { - const defaults: SidebarModulesConfig = {} - for (const sec of sectionDefs) { - defaults[sec.key] = { enabled: true } - for (const mod of sec.modules) defaults[sec.key][mod.key] = true + const loadConfig = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return api + .get('/api/user/self') + .then((res) => { + if (!current()) return + if (res.data.success && res.data.data?.sidebar_modules) { + const raw = res.data.data.sidebar_modules + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw + setConfig(parsed) + } else { + const defaults: SidebarModulesConfig = {} + for (const sec of sectionDefs) { + defaults[sec.key] = { enabled: true } + for (const mod of sec.modules) defaults[sec.key][mod.key] = true + } + setConfig(defaults) } - setConfig(defaults) - } - } catch { - /* ignore */ - } + }) + .catch(() => { + /* retain the last valid configuration */ + }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [beginRead, setConfig]) useEffect(() => { loadConfig() diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index 08b31bcc..182fa1b2 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -31,6 +31,8 @@ import { type CustomOAuthBinding, } from '@/lib/oauth' import { useDialogs } from '@/hooks/use-dialog' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { useStatus } from '@/hooks/use-status' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' @@ -61,7 +63,10 @@ export function AccountBindingsTab({ const { t } = useTranslation() const dialogs = useDialogs() const { status, loading } = useStatus() - const [customBindings, setCustomBindings] = useState([]) + const beginRead = useReadScope(profile?.id) + const [customBindings, setCustomBindings] = useResettableState< + CustomOAuthBinding[] + >([], profile?.id) const [unbindTarget, setUnbindTarget] = useState( null ) @@ -75,17 +80,19 @@ export function AccountBindingsTab({ | Array<{ id: number; name: string }> | undefined - const fetchCustomBindings = useCallback(async () => { - if (!customProviders || customProviders.length === 0) return - try { - const res = await getSelfOAuthBindings() - if (res.success && res.data) { - setCustomBindings(res.data) - } - } catch { - // ignore - } - }, [customProviders]) + const fetchCustomBindings = useCallback(() => { + if (!customProviders || customProviders.length === 0) + return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return getSelfOAuthBindings() + .then((res) => { + if (current() && res.success && res.data) setCustomBindings(res.data) + }) + .catch(() => { + /* retain the last successful read */ + }) + }, [customProviders, beginRead, setCustomBindings]) useEffect(() => { fetchCustomBindings() diff --git a/web/default/src/features/profile/components/tabs/notification-tab.tsx b/web/default/src/features/profile/components/tabs/notification-tab.tsx index 02190504..937b314c 100644 --- a/web/default/src/features/profile/components/tabs/notification-tab.tsx +++ b/web/default/src/features/profile/components/tabs/notification-tab.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useCallback } from 'react' +import { useState, useCallback } from 'react' import { Bell, Loader2, Mail, Server, Webhook } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ROLE } from '@/lib/roles' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -55,52 +56,50 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { const { t } = useTranslation() const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN const [loading, setLoading] = useState(false) - const [settings, setSettings] = useState({ - notify_type: 'email', - quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD, - notification_email: '', - webhook_url: '', - webhook_secret: '', - bark_url: '', - gotify_url: '', - gotify_token: '', - gotify_priority: 5, - accept_unset_model_ratio_model: false, - record_ip_log: false, - upstream_model_update_notify_enabled: false, - }) + const [settings, setSettings] = useResettableState(() => { + if (!profile?.setting) + return { + notify_type: 'email', + quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD, + notification_email: '', + webhook_url: '', + webhook_secret: '', + bark_url: '', + gotify_url: '', + gotify_token: '', + gotify_priority: 5, + accept_unset_model_ratio_model: false, + record_ip_log: false, + upstream_model_update_notify_enabled: false, + } + const parsed = parseUserSettings(profile.setting) + return { + notify_type: parsed.notify_type || 'email', + quota_warning_threshold: + parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD, + notification_email: parsed.notification_email ?? '', + webhook_url: parsed.webhook_url ?? '', + webhook_secret: parsed.webhook_secret ?? '', + bark_url: parsed.bark_url ?? '', + gotify_url: parsed.gotify_url ?? '', + gotify_token: parsed.gotify_token ?? '', + gotify_priority: parsed.gotify_priority ?? 5, + accept_unset_model_ratio_model: + parsed.accept_unset_model_ratio_model || false, + record_ip_log: parsed.record_ip_log || false, + upstream_model_update_notify_enabled: + parsed.upstream_model_update_notify_enabled || false, + } + }, profile) // Update form field helper const updateField = useCallback( (field: K, value: UserSettings[K]) => { setSettings((prev) => ({ ...prev, [field]: value })) }, - [] + [setSettings] ) - useEffect(() => { - if (profile?.setting) { - const parsed = parseUserSettings(profile.setting) - setSettings({ - notify_type: parsed.notify_type || 'email', - quota_warning_threshold: - parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD, - notification_email: parsed.notification_email ?? '', - webhook_url: parsed.webhook_url ?? '', - webhook_secret: parsed.webhook_secret ?? '', - bark_url: parsed.bark_url ?? '', - gotify_url: parsed.gotify_url ?? '', - gotify_token: parsed.gotify_token ?? '', - gotify_priority: parsed.gotify_priority ?? 5, - accept_unset_model_ratio_model: - parsed.accept_unset_model_ratio_model || false, - record_ip_log: parsed.record_ip_log || false, - upstream_model_update_notify_enabled: - parsed.upstream_model_update_notify_enabled || false, - }) - } - }, [profile]) - const handleSave = async () => { try { setLoading(true) diff --git a/web/default/src/features/profile/hooks/use-profile.ts b/web/default/src/features/profile/hooks/use-profile.ts index 2b404068..79062fff 100644 --- a/web/default/src/features/profile/hooks/use-profile.ts +++ b/web/default/src/features/profile/hooks/use-profile.ts @@ -19,6 +19,10 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { useState, useEffect, useCallback } from 'react' import i18next from 'i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { getUserProfile, updateUserProfile, updateUserSettings } from '../api' import type { UserProfile, @@ -31,33 +35,46 @@ import type { // ============================================================================ export function useProfile() { - const [profile, setProfile] = useState(null) - const [loading, setLoading] = useState(true) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope(sessionRevision) + const [profile, setProfile] = useResettableState( + null, + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) const [updating, setUpdating] = useState(false) - // Fetch user profile (with optional silent mode) - const fetchProfile = useCallback(async (silent = false) => { - try { - if (!silent) { - setLoading(true) - } - const response = await getUserProfile() - - if (response.success && response.data) { - setProfile(response.data) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch profile:', error) - if (!silent) { - toast.error(i18next.t('Failed to load profile')) - } - } finally { - if (!silent) { - setLoading(false) - } - } - }, []) + const loadProfile = useCallback( + (silent = false) => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getUserProfile() + .then((response) => { + if (current() && response.success && response.data) + setProfile(response.data) + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch profile:', + getSafeErrorDebugInfo(error) + ) + if (!silent) toast.error(i18next.t('Failed to load profile')) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, + [beginRead, setProfile, setLoading] + ) + const fetchProfile = useCallback( + (silent = false) => { + if (!silent) setLoading(true) + return loadProfile(silent) + }, + [loadProfile, setLoading] + ) // Refresh profile silently (without loading state) const refreshProfile = useCallback(async () => { @@ -81,7 +98,7 @@ export function useProfile() { return false } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to update profile:', error) + console.error('Failed to update profile:', getSafeErrorDebugInfo(error)) toast.error(i18next.t('Failed to update profile')) return false } finally { @@ -108,7 +125,10 @@ export function useProfile() { return false } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to update settings:', error) + console.error( + 'Failed to update settings:', + getSafeErrorDebugInfo(error) + ) toast.error(i18next.t('Failed to update settings')) return false } finally { @@ -120,8 +140,8 @@ export function useProfile() { // Initial fetch useEffect(() => { - fetchProfile() - }, [fetchProfile]) + void loadProfile() + }, [loadProfile]) return { profile, diff --git a/web/default/src/features/profile/hooks/use-two-fa.ts b/web/default/src/features/profile/hooks/use-two-fa.ts index cabd66d7..623592f9 100644 --- a/web/default/src/features/profile/hooks/use-two-fa.ts +++ b/web/default/src/features/profile/hooks/use-two-fa.ts @@ -16,8 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useCallback } from 'react' +import { useEffect, useCallback } from 'react' +import { useAuthStore } from '@/stores/auth-store' import { get2FAStatus } from '@/lib/api' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import type { TwoFAStatus } from '../types' // ============================================================================ @@ -31,29 +35,44 @@ const DEFAULT_STATUS: TwoFAStatus = { } export function useTwoFA(enabled = true) { - const [loading, setLoading] = useState(true) - const [status, setStatus] = useState(DEFAULT_STATUS) - - const fetchStatus = useCallback(async () => { - if (!enabled) return - - try { - setLoading(true) - const response = await get2FAStatus() - if (response.success && response.data) { - setStatus(response.data) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch 2FA status:', error) - } finally { - setLoading(false) - } - }, [enabled]) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const scopeKey = JSON.stringify([sessionRevision, enabled]) + const beginRead = useReadScope(scopeKey) + const [loading, setLoading] = useResettableState(enabled, scopeKey) + const [status, setStatus] = useResettableState( + DEFAULT_STATUS, + scopeKey + ) + const loadStatus = useCallback(() => { + if (!enabled) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return get2FAStatus() + .then((response) => { + if (current() && response.success && response.data) + setStatus(response.data) + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch 2FA status:', + getSafeErrorDebugInfo(error) + ) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [enabled, beginRead, setStatus, setLoading]) + const fetchStatus = useCallback(() => { + if (!enabled) return Promise.resolve() + setLoading(true) + return loadStatus() + }, [enabled, loadStatus, setLoading]) useEffect(() => { - fetchStatus() - }, [fetchStatus]) + void loadStatus() + }, [loadStatus]) return { status, diff --git a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx index f67da938..7afdcf8f 100644 --- a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx @@ -16,12 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { Crown, CalendarClock, Package } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store' import { formatQuota } from '@/lib/format' +import { useResettableState } from '@/hooks/use-resettable-state' import { useSystemConfig } from '@/hooks/use-system-config' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -75,15 +76,14 @@ export function SubscriptionPurchaseDialog(props: Props) { const { t } = useTranslation() const { currency } = useSystemConfig() const [paying, setPaying] = useState(false) - const [selectedEpayMethod, setSelectedEpayMethod] = useState('') - - useEffect(() => { - if (props.open && props.epayMethods && props.epayMethods.length > 0) { - setSelectedEpayMethod(props.epayMethods[0].type) - } else if (!props.open) { - setSelectedEpayMethod('') - } - }, [props.open, props.epayMethods]) + const [selectedEpayMethod, setSelectedEpayMethod] = useResettableState( + props.open ? props.epayMethods?.[0]?.type || '' : '', + JSON.stringify([ + props.open, + props.plan?.plan.id, + props.epayMethods?.map((method) => method.type), + ]) + ) const plan = props.plan?.plan if (!plan) return null diff --git a/web/default/src/features/system-settings/content/announcements-section.tsx b/web/default/src/features/system-settings/content/announcements-section.tsx index 5e5228c8..1adb3915 100644 --- a/web/default/src/features/system-settings/content/announcements-section.tsx +++ b/web/default/src/features/system-settings/content/announcements-section.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' @@ -24,6 +24,7 @@ import { Plus, Edit, Trash2, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import dayjs from '@/lib/dayjs' +import { useResettableState } from '@/hooks/use-resettable-state' import { AlertDialog, AlertDialogAction, @@ -144,8 +145,20 @@ export function AnnouncementsSection({ }: AnnouncementsSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() - const [announcements, setAnnouncements] = useState([]) - const [isEnabled, setIsEnabled] = useState(enabled) + const [announcements, setAnnouncements] = useResettableState( + () => { + try { + const parsed = JSON.parse(data || '[]') + return Array.isArray(parsed) + ? parsed.map((item, index) => ({ ...item, id: item.id || index + 1 })) + : [] + } catch { + return [] + } + }, + data + ) + const [isEnabled, setIsEnabled] = useResettableState(enabled, enabled) const [hasChanges, setHasChanges] = useState(false) const [selectedIds, setSelectedIds] = useState([]) const [showDialog, setShowDialog] = useState(false) @@ -164,26 +177,6 @@ export function AnnouncementsSection({ }, }) - useEffect(() => { - try { - const parsed = JSON.parse(data || '[]') - if (Array.isArray(parsed)) { - setAnnouncements( - parsed.map((item, idx) => ({ - ...item, - id: item.id || idx + 1, - })) - ) - } - } catch { - setAnnouncements([]) - } - }, [data]) - - useEffect(() => { - setIsEnabled(enabled) - }, [enabled]) - const handleToggleEnabled = async (checked: boolean) => { try { await updateOption.mutateAsync({ diff --git a/web/default/src/features/system-settings/content/api-info-section.tsx b/web/default/src/features/system-settings/content/api-info-section.tsx index 954e9910..f323cb98 100644 --- a/web/default/src/features/system-settings/content/api-info-section.tsx +++ b/web/default/src/features/system-settings/content/api-info-section.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' @@ -24,6 +24,7 @@ import { Plus, Edit, Trash2, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { getBgColorClass } from '@/lib/colors' +import { useResettableState } from '@/hooks/use-resettable-state' import { AlertDialog, AlertDialogAction, @@ -119,8 +120,17 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() const apiInfoSchema = createApiInfoSchema(t) - const [apiInfoList, setApiInfoList] = useState([]) - const [isEnabled, setIsEnabled] = useState(enabled) + const [apiInfoList, setApiInfoList] = useResettableState(() => { + try { + const parsed = JSON.parse(data || '[]') + return Array.isArray(parsed) + ? parsed.map((item, index) => ({ ...item, id: item.id || index + 1 })) + : [] + } catch { + return [] + } + }, data) + const [isEnabled, setIsEnabled] = useResettableState(enabled, enabled) const [hasChanges, setHasChanges] = useState(false) const [selectedIds, setSelectedIds] = useState([]) const [showDialog, setShowDialog] = useState(false) @@ -138,26 +148,6 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { }, }) - useEffect(() => { - try { - const parsed = JSON.parse(data || '[]') - if (Array.isArray(parsed)) { - setApiInfoList( - parsed.map((item, idx) => ({ - ...item, - id: item.id || idx + 1, - })) - ) - } - } catch { - setApiInfoList([]) - } - }, [data]) - - useEffect(() => { - setIsEnabled(enabled) - }, [enabled]) - const handleToggleEnabled = async (checked: boolean) => { try { await updateOption.mutateAsync({ diff --git a/web/default/src/features/system-settings/content/faq-section.tsx b/web/default/src/features/system-settings/content/faq-section.tsx index ed681c18..fecf4f36 100644 --- a/web/default/src/features/system-settings/content/faq-section.tsx +++ b/web/default/src/features/system-settings/content/faq-section.tsx @@ -16,13 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Plus, Edit, Trash2, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { AlertDialog, AlertDialogAction, @@ -93,8 +94,17 @@ type FAQFormValues = z.infer export function FAQSection({ enabled, data }: FAQSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() - const [faqList, setFaqList] = useState([]) - const [isEnabled, setIsEnabled] = useState(enabled) + const [faqList, setFaqList] = useResettableState(() => { + try { + const parsed = JSON.parse(data || '[]') + return Array.isArray(parsed) + ? parsed.map((item, index) => ({ ...item, id: item.id || index + 1 })) + : [] + } catch { + return [] + } + }, data) + const [isEnabled, setIsEnabled] = useResettableState(enabled, enabled) const [hasChanges, setHasChanges] = useState(false) const [selectedIds, setSelectedIds] = useState([]) const [showDialog, setShowDialog] = useState(false) @@ -110,26 +120,6 @@ export function FAQSection({ enabled, data }: FAQSectionProps) { }, }) - useEffect(() => { - try { - const parsed = JSON.parse(data || '[]') - if (Array.isArray(parsed)) { - setFaqList( - parsed.map((item, idx) => ({ - ...item, - id: item.id || idx + 1, - })) - ) - } - } catch { - setFaqList([]) - } - }, [data]) - - useEffect(() => { - setIsEnabled(enabled) - }, [enabled]) - const handleToggleEnabled = async (checked: boolean) => { try { await updateOption.mutateAsync({ diff --git a/web/default/src/features/system-settings/content/uptime-kuma-section.tsx b/web/default/src/features/system-settings/content/uptime-kuma-section.tsx index f0b5f51b..92c3e82a 100644 --- a/web/default/src/features/system-settings/content/uptime-kuma-section.tsx +++ b/web/default/src/features/system-settings/content/uptime-kuma-section.tsx @@ -16,13 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Plus, Edit, Trash2, Save } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { AlertDialog, AlertDialogAction, @@ -101,8 +102,17 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() const uptimeKumaSchema = createUptimeKumaSchema(t) - const [groups, setGroups] = useState([]) - const [isEnabled, setIsEnabled] = useState(enabled) + const [groups, setGroups] = useResettableState(() => { + try { + const parsed = JSON.parse(data || '[]') + return Array.isArray(parsed) + ? parsed.map((item, index) => ({ ...item, id: item.id || index + 1 })) + : [] + } catch { + return [] + } + }, data) + const [isEnabled, setIsEnabled] = useResettableState(enabled, enabled) const [hasChanges, setHasChanges] = useState(false) const [selectedIds, setSelectedIds] = useState([]) const [showDialog, setShowDialog] = useState(false) @@ -119,26 +129,6 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) { }, }) - useEffect(() => { - try { - const parsed = JSON.parse(data || '[]') - if (Array.isArray(parsed)) { - setGroups( - parsed.map((item, idx) => ({ - ...item, - id: item.id || idx + 1, - })) - ) - } - } catch { - setGroups([]) - } - }, [data]) - - useEffect(() => { - setIsEnabled(enabled) - }, [enabled]) - const handleToggleEnabled = async (checked: boolean) => { try { await updateOption.mutateAsync({ diff --git a/web/default/src/features/system-settings/general/channel-affinity/index.tsx b/web/default/src/features/system-settings/general/channel-affinity/index.tsx index 196fb1a9..ae98b32b 100644 --- a/web/default/src/features/system-settings/general/channel-affinity/index.tsx +++ b/web/default/src/features/system-settings/general/channel-affinity/index.tsx @@ -20,6 +20,8 @@ import { useCallback, useEffect, useState } from 'react' import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -94,37 +96,46 @@ export function ChannelAffinitySection(props: Props) { const { t } = useTranslation() const updateOption = useUpdateOption() - const [enabled, setEnabled] = useState( - props.defaultValues['channel_affinity_setting.enabled'] + const [enabled, setEnabled] = useResettableState( + props.defaultValues['channel_affinity_setting.enabled'], + props.defaultValues ) - const [switchOnSuccess, setSwitchOnSuccess] = useState( - props.defaultValues['channel_affinity_setting.switch_on_success'] + const [switchOnSuccess, setSwitchOnSuccess] = useResettableState( + props.defaultValues['channel_affinity_setting.switch_on_success'], + props.defaultValues ) - const [keepOnChannelDisabled, setKeepOnChannelDisabled] = useState( - props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'] + const [keepOnChannelDisabled, setKeepOnChannelDisabled] = useResettableState( + props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'], + props.defaultValues ) - const [maxEntries, setMaxEntries] = useState( - props.defaultValues['channel_affinity_setting.max_entries'] + const [maxEntries, setMaxEntries] = useResettableState( + props.defaultValues['channel_affinity_setting.max_entries'], + props.defaultValues ) - const [defaultTtl, setDefaultTtl] = useState( - props.defaultValues['channel_affinity_setting.default_ttl_seconds'] + const [defaultTtl, setDefaultTtl] = useResettableState( + props.defaultValues['channel_affinity_setting.default_ttl_seconds'], + props.defaultValues ) - const [rules, setRules] = useState(() => - parseRules(props.defaultValues['channel_affinity_setting.rules']) + const [rules, setRules] = useResettableState( + () => parseRules(props.defaultValues['channel_affinity_setting.rules']), + props.defaultValues ) const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') - const [jsonText, setJsonText] = useState(() => - JSON.stringify( - parseRules(props.defaultValues['channel_affinity_setting.rules']).map( - ({ id: _, ...r }) => r + const [jsonText, setJsonText] = useResettableState( + () => + JSON.stringify( + parseRules(props.defaultValues['channel_affinity_setting.rules']).map( + ({ id: _, ...r }) => r + ), + null, + 2 ), - null, - 2 - ) + props.defaultValues ) const [cacheStats, setCacheStats] = useState(null) - const [cacheLoading, setCacheLoading] = useState(false) + const [cacheLoading, setCacheLoading] = useState(true) + const beginRead = useReadScope() const [saving, setSaving] = useState(false) const [ruleEditorOpen, setRuleEditorOpen] = useState(false) @@ -134,46 +145,27 @@ export function ChannelAffinitySection(props: Props) { const [clearRuleName, setClearRuleName] = useState(null) const [fillTemplateDialogOpen, setFillTemplateDialogOpen] = useState(false) - useEffect(() => { - setEnabled(props.defaultValues['channel_affinity_setting.enabled']) - setSwitchOnSuccess( - props.defaultValues['channel_affinity_setting.switch_on_success'] - ) - setKeepOnChannelDisabled( - props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'] - ) - setMaxEntries(props.defaultValues['channel_affinity_setting.max_entries']) - setDefaultTtl( - props.defaultValues['channel_affinity_setting.default_ttl_seconds'] - ) - const parsed = parseRules( - props.defaultValues['channel_affinity_setting.rules'] - ) - setRules(parsed) - setJsonText( - JSON.stringify( - parsed.map(({ id: _, ...r }) => r), - null, - 2 - ) - ) - }, [props.defaultValues]) - - const refreshCache = useCallback(async () => { + const loadCache = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getCacheStats() + .then((res) => { + if (current() && res.success) setCacheStats(res.data || null) + }) + .catch(() => { + if (current()) toast.error(t('Failed to refresh cache stats')) + }) + .finally(() => { + if (current()) setCacheLoading(false) + }) + }, [beginRead, t]) + const refreshCache = useCallback(() => { setCacheLoading(true) - try { - const res = await getCacheStats() - if (res.success) setCacheStats(res.data || null) - } catch { - toast.error(t('Failed to refresh cache stats')) - } finally { - setCacheLoading(false) - } - }, [t]) - + return loadCache() + }, [loadCache]) useEffect(() => { - refreshCache() - }, [refreshCache]) + void loadCache() + }, [loadCache]) const appendCliTemplates = () => { const existingNames = new Set( diff --git a/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx b/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx index 3b8395ac..53637015 100644 --- a/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx +++ b/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { CheckSquare, RefreshCcw } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -145,7 +145,7 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { const [channelDialogOpen, setChannelDialogOpen] = useState(false) const [conflictDialogOpen, setConflictDialogOpen] = useState(false) const [selectedChannelIds, setSelectedChannelIds] = useState([]) - const [channelEndpoints, setChannelEndpoints] = useState< + const [endpointOverrides, setChannelEndpoints] = useState< Record >({}) const [differences, setDifferences] = useState({}) @@ -159,25 +159,15 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { enabled: channelDialogOpen, }) - // Memoize the channels list so the effect below only re-runs when the query - // data actually changes, instead of on every render (the `|| []` fallback - // would otherwise produce a new array reference each render). const channels = useMemo(() => channelsData?.data ?? [], [channelsData?.data]) - - useEffect(() => { - if (channels.length === 0) return - setChannelEndpoints((prev) => { - let mutated = false - const next = { ...prev } - for (const channel of channels) { - if (!next[channel.id]) { - next[channel.id] = getDefaultEndpointForChannel(channel) - mutated = true - } - } - return mutated ? next : prev - }) - }, [channels]) + const channelEndpoints = useMemo(() => { + const endpoints = { ...endpointOverrides } + for (const channel of channels) { + if (endpoints[channel.id] === undefined) + endpoints[channel.id] = getDefaultEndpointForChannel(channel) + } + return endpoints + }, [channels, endpointOverrides]) const fetchMutation = useMutation({ mutationFn: fetchUpstreamRatios, diff --git a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog-race.runtime.test.tsx b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog-race.runtime.test.tsx new file mode 100644 index 00000000..941f6ba9 --- /dev/null +++ b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog-race.runtime.test.tsx @@ -0,0 +1,84 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { createReactTestEnvironment } from '@/test/react' +import { waitFor, within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const env = createReactTestEnvironment() +await env.setup() +const { UserInfoDialog } = await import('./user-info-dialog') +after(() => env.teardown()) + +test('a late user response cannot replace the selected user or their balance', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + const requests: string[] = [] + api.defaults.adapter = async (config) => { + const id = String(config.url?.split('/').at(-1)) + requests.push(id) + await new Promise((resolve) => pending.set(id, resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { + username: `user-${id}`, + quota: id === '1' ? 900000 : 0, + used_quota: 0, + request_count: 0, + }, + }, + } + } + function Harness() { + const [id, setId] = useState(1) + return ( + <> + + {}} /> + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + await waitFor(() => assert.ok(pending.has('1'))) + await view.click(within(view.container).getByText('Select another user')) + await waitFor(() => assert.ok(pending.has('2'))) + await act(async () => pending.get('2')!()) + assert.ok(body.queryByText('user-2')) + await act(async () => pending.get('1')!()) + assert.ok(body.queryByText('user-2')) + assert.equal(body.queryByText('user-1'), null) + await act(async () => { + await env.i18n.changeLanguage('fr') + }) + assert.deepEqual(requests, ['1', '2']) + } finally { + for (const resolve of pending.values()) resolve() + await view.unmount() + api.defaults.adapter = adapter + } +}) diff --git a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx index 5d8cadc8..dcec83d0 100644 --- a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx @@ -16,11 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useEffectEvent } from 'react' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { formatQuota, formatCompactNumber } from '@/lib/format' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Dialog, DialogContent, @@ -58,35 +60,35 @@ export function UserInfoDialog({ onOpenChange, }: UserInfoDialogProps) { const { t } = useTranslation() - const [userInfo, setUserInfo] = useState(null) - const [isLoading, setIsLoading] = useState(false) - - const fetchUserInfo = useCallback( - async (id: number) => { - setIsLoading(true) - try { - const result = await getUserInfo(id) - if (result.success) { - setUserInfo(result.data || null) - } else { - toast.error(result.message || t('Failed to fetch user information')) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch user info:', error) - toast.error(t('Failed to fetch user information')) - } finally { - setIsLoading(false) - } - }, - [t] + const scopeKey = JSON.stringify([open, userId]) + const beginRead = useReadScope(scopeKey) + const [userInfo, setUserInfo] = useResettableState( + null, + beginRead + ) + const [isLoading, setIsLoading] = useResettableState( + Boolean(open && userId), + beginRead + ) + const reportFailure = useEffectEvent((message?: string) => + toast.error(message || t('Failed to fetch user information')) ) - useEffect(() => { - if (open && userId) { - fetchUserInfo(userId) - } - }, [open, userId, fetchUserInfo]) + if (!open || !userId) return + const current = beginRead() + void getUserInfo(userId) + .then((result) => { + if (!current()) return + if (result.success) setUserInfo(result.data || null) + else reportFailure(result.message) + }) + .catch(() => { + if (current()) reportFailure() + }) + .finally(() => { + if (current()) setIsLoading(false) + }) + }, [open, userId, beginRead, setUserInfo, setIsLoading]) return ( diff --git a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx index dbdc4ef5..ad721803 100644 --- a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx +++ b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx @@ -16,7 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { + useState, + useEffect, + useEffectEvent, + useCallback, + useMemo, +} from 'react' import { Mail, Globe, @@ -32,6 +38,8 @@ import { useTranslation } from 'react-i18next' import { SiGithub, SiDiscord } from 'react-icons/si' import { toast } from 'sonner' import { api } from '@/lib/api' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -165,58 +173,79 @@ function CustomProviderIcon(props: { iconUrl?: string }) { export function UserBindingDialog(props: Props) { const { t } = useTranslation() - const [user, setUser] = useState(null) - const [oauthBindings, setOauthBindings] = useState([]) - const [statusInfo, setStatusInfo] = useState({}) - const [loading, setLoading] = useState(false) - const [showBoundOnly, setShowBoundOnly] = useState(true) - const [unbindTarget, setUnbindTarget] = useState(null) + const beginRead = useReadScope(JSON.stringify([props.open, props.userId])) + const [user, setUser] = useResettableState(null, beginRead) + const [oauthBindings, setOauthBindings] = useResettableState( + [], + beginRead + ) + const [statusInfo, setStatusInfo] = useResettableState( + {}, + beginRead + ) + const [loading, setLoading] = useResettableState( + Boolean(props.open && props.userId), + beginRead + ) + const [showBoundOnly, setShowBoundOnly] = useResettableState(true, beginRead) + const [unbindTarget, setUnbindTarget] = + useResettableState(null, beginRead) const [unbinding, setUnbinding] = useState(false) - const fetchData = useCallback(async () => { - if (!props.userId) return - setLoading(true) - try { - const [userRes, oauthRes, statusRes] = await Promise.all([ - getUser(props.userId), - getUserOAuthBindings(props.userId).catch(() => ({ + const loadData = useCallback(() => { + if (!props.userId) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return Promise.all([ + getUser(props.userId), + getUserOAuthBindings(props.userId).catch(() => ({ + success: false, + data: [], + })), + api + .get('/api/status') + .then((r) => r.data) + .catch(() => ({ success: false, - data: [], + data: {}, })), - api - .get('/api/status') - .then((r) => r.data) - .catch(() => ({ - success: false, - data: {}, - })), - ]) - if (userRes.success && userRes.data) { - setUser(userRes.data) - } - if (oauthRes.success && oauthRes.data) { - setOauthBindings(oauthRes.data as OAuthBinding[]) - } - if (statusRes.success && statusRes.data) { - setStatusInfo(statusRes.data as StatusInfo) - } - } catch { - toast.error(t('Failed to load')) - } finally { - setLoading(false) - } - }, [props.userId, t]) + ]) + .then(([userRes, oauthRes, statusRes]) => { + if (!current()) return + if (userRes.success && userRes.data) { + setUser(userRes.data) + } + if (oauthRes.success && oauthRes.data) { + setOauthBindings(oauthRes.data as OAuthBinding[]) + } + if (statusRes.success && statusRes.data) { + setStatusInfo(statusRes.data as StatusInfo) + } + }) + .catch(() => { + if (current()) toast.error(t('Failed to load')) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [ + props.userId, + beginRead, + setUser, + setOauthBindings, + setStatusInfo, + setLoading, + t, + ]) + const fetchData = useCallback(() => { + setLoading(true) + return loadData() + }, [loadData, setLoading]) + const readInitialData = useEffectEvent(() => loadData()) useEffect(() => { - if (props.open && props.userId) { - setShowBoundOnly(true) - fetchData() - } else { - setUser(null) - setOauthBindings([]) - setStatusInfo({}) - } - }, [props.open, props.userId, fetchData]) + if (props.open && props.userId) void readInitialData() + }, [props.open, props.userId, beginRead]) const allBindings = useMemo(() => { const items: BindingItem[] = [] diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index 351efe54..ddb239bc 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -16,11 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' import { Gift, ExternalLink, Loader2, Receipt, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { formatNumber } from '@/lib/format' import { cn } from '@/lib/utils' +import { useResettableState } from '@/hooks/use-resettable-state' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' @@ -110,11 +110,10 @@ export function RechargeFormCard({ enableWaffoPancakeTopup, }: RechargeFormCardProps) { const { t } = useTranslation() - const [localAmount, setLocalAmount] = useState(topupAmount.toString()) - - useEffect(() => { - setLocalAmount(topupAmount.toString()) - }, [topupAmount]) + const [localAmount, setLocalAmount] = useResettableState( + topupAmount.toString(), + topupAmount + ) const handleAmountChange = (value: string) => { setLocalAmount(value) diff --git a/web/default/src/features/wallet/components/subscription-plans-card.tsx b/web/default/src/features/wallet/components/subscription-plans-card.tsx index d3cc0315..9ed94120 100644 --- a/web/default/src/features/wallet/components/subscription-plans-card.tsx +++ b/web/default/src/features/wallet/components/subscription-plans-card.tsx @@ -109,6 +109,7 @@ export function SubscriptionPlansCard({ useState('subscription_first') const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) + const [now, setNow] = useState(() => Date.now() / 1000) const [purchaseOpen, setPurchaseOpen] = useState(false) const [selectedPlan, setSelectedPlan] = useState(null) @@ -122,40 +123,63 @@ export function SubscriptionPlansCard({ [topupInfo?.pay_methods] ) - const fetchPlans = useCallback(async () => { - try { - const res = await getPublicPlans() - if (res.success) { - setPlans(res.data || []) - } - } catch { - setPlans([]) - } - }, []) + const fetchPlans = useCallback( + () => + getPublicPlans() + .then((res) => { + if (res.success) setPlans(res.data || []) + }) + .catch(() => setPlans([])), + [] + ) - const fetchSelfSubscription = useCallback(async () => { - try { - const res = await getSelfSubscriptionFull() - if (res.success && res.data) { - setBillingPreference( - res.data.billing_preference || 'subscription_first' - ) - setActiveSubscriptions(res.data.subscriptions || []) - setAllSubscriptions(res.data.all_subscriptions || []) - } - } catch { - // ignore - } - }, []) + const fetchSelfSubscription = useCallback( + () => + getSelfSubscriptionFull() + .then((res) => { + if (res.success && res.data) { + setBillingPreference( + res.data.billing_preference || 'subscription_first' + ) + setActiveSubscriptions(res.data.subscriptions || []) + setAllSubscriptions(res.data.all_subscriptions || []) + setNow(Date.now() / 1000) + } + }) + .catch(() => { + /* retain the last successful read */ + }), + [] + ) useEffect(() => { - const init = async () => { - setLoading(true) - await Promise.all([fetchPlans(), fetchSelfSubscription()]) - setLoading(false) + let current = true + void Promise.all([fetchPlans(), fetchSelfSubscription()]).then(() => { + if (current) setLoading(false) + }) + return () => { + current = false } - init() }, [fetchPlans, fetchSelfSubscription]) + useEffect(() => { + const current = Date.now() + const day = 86_400_000 + let next = Infinity + for (const { subscription } of allSubscriptions) { + const remaining = (subscription.end_time || 0) * 1000 - current + if ( + subscription.status !== 'active' || + !Number.isFinite(remaining) || + remaining < 0 + ) + continue + const boundary = remaining % day || (remaining === 0 ? 0 : day) + next = Math.min(next, boundary + 1) + } + if (!Number.isFinite(next)) return + const timer = setTimeout(() => setNow(Date.now() / 1000), next) + return () => clearTimeout(timer) + }, [allSubscriptions, now]) const handleRefresh = async () => { setRefreshing(true) @@ -222,7 +246,6 @@ export function SubscriptionPlansCard({ const getRemainingDays = (sub: UserSubscriptionRecord) => { const endTime = sub?.subscription?.end_time || 0 if (!endTime) return 0 - const now = Date.now() / 1000 return Math.max(0, Math.ceil((endTime - now) / 86400)) } @@ -403,7 +426,6 @@ export function SubscriptionPlansCard({ planTitleMap.get(subscription?.plan_id) || '' const remainDays = getRemainingDays(sub) const usagePercent = getUsagePercent(sub) - const now = Date.now() / 1000 const isExpired = (subscription?.end_time || 0) < now const isCancelled = subscription?.status === 'cancelled' const isActive = diff --git a/web/default/src/features/wallet/hooks/use-affiliate.ts b/web/default/src/features/wallet/hooks/use-affiliate.ts index 0876c9d0..90d1e7e6 100644 --- a/web/default/src/features/wallet/hooks/use-affiliate.ts +++ b/web/default/src/features/wallet/hooks/use-affiliate.ts @@ -19,8 +19,12 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { useState, useEffect, useCallback } from 'react' import i18next from 'i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { getAffiliateCode, transferAffiliateQuota } from '../api' import { generateAffiliateLink } from '../lib' @@ -29,30 +33,46 @@ import { generateAffiliateLink } from '../lib' // ============================================================================ export function useAffiliate() { - const [affiliateCode, setAffiliateCode] = useState('') - const [affiliateLink, setAffiliateLink] = useState('') - const [loading, setLoading] = useState(true) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope(sessionRevision) + const [affiliateCode, setAffiliateCode] = useResettableState( + '', + sessionRevision + ) + const [affiliateLink, setAffiliateLink] = useResettableState( + '', + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) const [transferring, setTransferring] = useState(false) const { copyToClipboard } = useCopyToClipboard() - // Fetch affiliate code - const fetchAffiliateCode = useCallback(async () => { - try { - setLoading(true) - const response = await getAffiliateCode() - - if (response.success && response.data) { - setAffiliateCode(response.data) - const link = generateAffiliateLink(response.data) - setAffiliateLink(link) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch affiliate code:', error) - } finally { - setLoading(false) - } - }, []) + const loadAffiliateCode = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getAffiliateCode() + .then((response) => { + if (current() && response.success && response.data) { + setAffiliateCode(response.data) + setAffiliateLink(generateAffiliateLink(response.data)) + } + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch affiliate code:', + getSafeErrorDebugInfo(error) + ) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [beginRead, setAffiliateCode, setAffiliateLink, setLoading]) + const fetchAffiliateCode = useCallback(() => { + setLoading(true) + return loadAffiliateCode() + }, [loadAffiliateCode, setLoading]) // Copy affiliate link const copyAffiliateLink = useCallback(() => { @@ -82,8 +102,8 @@ export function useAffiliate() { }, []) useEffect(() => { - fetchAffiliateCode() - }, [fetchAffiliateCode]) + void loadAffiliateCode() + }, [loadAffiliateCode]) return { affiliateCode, diff --git a/web/default/src/features/wallet/hooks/use-topup-info.ts b/web/default/src/features/wallet/hooks/use-topup-info.ts index 42e13b80..fc119401 100644 --- a/web/default/src/features/wallet/hooks/use-topup-info.ts +++ b/web/default/src/features/wallet/hooks/use-topup-info.ts @@ -16,7 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' +import { useCallback, useEffect } from 'react' +import { useAuthStore } from '@/stores/auth-store' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { getTopupInfo } from '../api' import { generatePresetAmounts, @@ -162,60 +166,75 @@ function parseDiscountMap(data: unknown): Record { } export function useTopupInfo() { - const [topupInfo, setTopupInfo] = useState(null) - const [presetAmounts, setPresetAmounts] = useState([]) - const [loading, setLoading] = useState(true) - - const fetchTopupInfo = async () => { - try { - setLoading(true) - - const response = await getTopupInfo() - - if (!response.success || !response.data) { + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope(sessionRevision) + const [topupInfo, setTopupInfo] = useResettableState( + null, + sessionRevision + ) + const [presetAmounts, setPresetAmounts] = useResettableState( + [], + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) + + const loadTopupInfo = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getTopupInfo() + .then((response) => { + if (!current()) return + + if (!response.success || !response.data) { + // eslint-disable-next-line no-console + console.error('Failed to fetch topup info') + return + } + + const processedData: TopupInfo = { + ...response.data, + pay_methods: parsePaymentMethods( + response.data.pay_methods, + response.data.stripe_min_topup + ), + amount_options: parseAmountOptions(response.data.amount_options), + discount: parseDiscountMap(response.data.discount), + creem_products: parseCreemProducts(response.data.creem_products), + waffo_pay_methods: parseWaffoPayMethods( + response.data.waffo_pay_methods + ), + } + + setTopupInfo(processedData) + + if (processedData.amount_options.length > 0) { + const customPresets = mergePresetAmounts( + processedData.amount_options, + processedData.discount || {} + ) + setPresetAmounts(customPresets) + } else { + const minTopup = getMinTopupAmount(processedData) + const defaultPresets = generatePresetAmounts(minTopup) + setPresetAmounts(defaultPresets) + } + }) + .catch((err) => { + if (!current()) return // eslint-disable-next-line no-console - console.error('Failed to fetch topup info:', response.message) - return - } - - const processedData: TopupInfo = { - ...response.data, - pay_methods: parsePaymentMethods( - response.data.pay_methods, - response.data.stripe_min_topup - ), - amount_options: parseAmountOptions(response.data.amount_options), - discount: parseDiscountMap(response.data.discount), - creem_products: parseCreemProducts(response.data.creem_products), - waffo_pay_methods: parseWaffoPayMethods( - response.data.waffo_pay_methods - ), - } - - setTopupInfo(processedData) - - if (processedData.amount_options.length > 0) { - const customPresets = mergePresetAmounts( - processedData.amount_options, - processedData.discount || {} - ) - setPresetAmounts(customPresets) - } else { - const minTopup = getMinTopupAmount(processedData) - const defaultPresets = generatePresetAmounts(minTopup) - setPresetAmounts(defaultPresets) - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('Failed to fetch topup info:', err) - } finally { - setLoading(false) - } - } - + console.error('Failed to fetch topup info:', getSafeErrorDebugInfo(err)) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [beginRead, setTopupInfo, setPresetAmounts, setLoading]) + const fetchTopupInfo = useCallback(() => { + setLoading(true) + return loadTopupInfo() + }, [loadTopupInfo, setLoading]) useEffect(() => { - fetchTopupInfo() - }, []) + void loadTopupInfo() + }, [loadTopupInfo]) return { topupInfo, diff --git a/web/default/src/features/wallet/index.tsx b/web/default/src/features/wallet/index.tsx index d44503a6..f9980a3d 100644 --- a/web/default/src/features/wallet/index.tsx +++ b/web/default/src/features/wallet/index.tsx @@ -18,7 +18,11 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { useStatus } from '@/hooks/use-status' import { useSystemConfig } from '@/hooks/use-system-config' import { SectionPageLayout } from '@/components/layout' @@ -58,16 +62,36 @@ interface WalletProps { export function Wallet(props: WalletProps) { const { t } = useTranslation() - const [user, setUser] = useState(null) - const [userLoading, setUserLoading] = useState(true) - const [topupAmount, setTopupAmount] = useState(0) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope() + const [user, setUser] = useResettableState( + null, + sessionRevision + ) + const [userLoading, setUserLoading] = useResettableState( + true, + sessionRevision + ) + const [topupAmountDraft, setTopupAmount] = useResettableState( + null, + sessionRevision + ) const [selectedPreset, setSelectedPreset] = useState(null) const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentLoading, setPaymentLoading] = useState(null) const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) const [transferDialogOpen, setTransferDialogOpen] = useState(false) - const [billingDialogOpen, setBillingDialogOpen] = useState(false) + const [billingDialogOpen, setBillingDialogOpen] = useState( + Boolean(props.initialShowHistory) + ) + const [previousShowHistory, setPreviousShowHistory] = useState( + props.initialShowHistory + ) + if (previousShowHistory !== props.initialShowHistory) { + setPreviousShowHistory(props.initialShowHistory) + if (props.initialShowHistory) setBillingDialogOpen(true) + } const [redemptionCode, setRedemptionCode] = useState('') const [creemDialogOpen, setCreemDialogOpen] = useState(false) const [selectedCreemProduct, setSelectedCreemProduct] = @@ -79,6 +103,8 @@ export function Wallet(props: WalletProps) { const { topupInfo, presetAmounts, loading: topupLoading } = useTopupInfo() // Calculate effective exchange rate - when display type is USD, use rate of 1 + const topupAmount = + topupAmountDraft ?? (topupInfo ? getMinTopupAmount(topupInfo) : 0) const effectiveUsdExchangeRate = useMemo(() => { return currency?.quotaDisplayType === 'USD' ? 1 @@ -103,44 +129,50 @@ export function Wallet(props: WalletProps) { const { processing: pancakeProcessing, processWaffoPancakePayment } = useWaffoPancakePayment() - // Fetch and refresh user data - const fetchUser = useCallback(async () => { - try { - setUserLoading(true) - const response = await getSelf() - if (response.success && response.data) { - setUser(response.data as UserWalletData) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch user data:', error) - } finally { - setUserLoading(false) - } - }, []) - + const loadUser = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getSelf() + .then((response) => { + if (current() && response.success && response.data) + setUser(response.data as UserWalletData) + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch user data:', + getSafeErrorDebugInfo(error) + ) + }) + .finally(() => { + if (current()) setUserLoading(false) + }) + }, [beginRead, setUser, setUserLoading]) + const fetchUser = useCallback(() => { + setUserLoading(true) + return loadUser() + }, [loadUser, setUserLoading]) useEffect(() => { - fetchUser() - }, [fetchUser]) + void loadUser() + }, [loadUser]) useEffect(() => { if (props.initialShowHistory) { - setBillingDialogOpen(true) window.history.replaceState({}, '', window.location.pathname) } }, [props.initialShowHistory]) // Initialize topup amount when topup info is loaded useEffect(() => { - if (topupInfo && topupAmount === 0) { + if (topupInfo && topupAmountDraft === null) { const minTopup = getMinTopupAmount(topupInfo) - setTopupAmount(minTopup) // Calculate initial payment amount with default payment type const defaultPaymentType = getDefaultPaymentType(topupInfo) calculatePaymentAmount(minTopup, defaultPaymentType) } - }, [topupInfo, topupAmount, calculatePaymentAmount]) + }, [topupInfo, topupAmountDraft, calculatePaymentAmount]) // Get current payment type (selected or default) const getCurrentPaymentType = useCallback(() => { diff --git a/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx b/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx new file mode 100644 index 00000000..9cb3aaf3 --- /dev/null +++ b/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx @@ -0,0 +1,330 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' +import { + subscriptionPlanSchema, + type PlanRecord, +} from '@/features/subscriptions/types' + +const env = createReactTestEnvironment() +await env.setup() +const storage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: env.dom.window.localStorage, +}) +Object.defineProperty(env.dom.window.HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value() {}, +}) +const { fireEvent, waitFor, within } = + await import('@testing-library/react/pure') +const { Wallet } = await import('./index') +const { SubscriptionPlansCard } = + await import('./components/subscription-plans-card') +const { SubscriptionPurchaseDialog } = + await import('@/features/subscriptions/components/dialogs/subscription-purchase-dialog') +after(() => { + env.teardown() + if (storage) Object.defineProperty(globalThis, 'localStorage', storage) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +test('wallet custom zero stays explicit instead of being initialized again', async () => { + const adapter = api.defaults.adapter + const quotes: number[] = [], + mutations: string[] = [] + api.defaults.adapter = async (config) => { + let data: unknown = { success: true, data: {} } + if (config.url === '/api/user/self') + data = { + success: true, + data: { + id: 1, + username: 'synthetic-user', + quota: 0, + used_quota: 0, + request_count: 0, + aff_quota: 0, + }, + } + else if (config.url === '/api/user/topup/info') + data = { + success: true, + data: { + enable_online_topup: true, + min_topup: 10, + pay_methods: [{ name: 'Synthetic method', type: 'alipay' }], + amount_options: [], + discount: {}, + stripe_min_topup: 10, + }, + } + else if (config.url === '/api/user/aff') data = { success: true, data: '' } + else if (config.url === '/api/subscription/plans') + data = { success: true, data: [] } + else if (config.url === '/api/subscription/self') + data = { + success: true, + data: { + billing_preference: 'wallet_first', + subscriptions: [], + all_subscriptions: [], + }, + } + else if (config.url === '/api/user/amount') { + const amount = JSON.parse(config.data).amount + quotes.push(amount) + data = { success: true, message: 'success', data: String(amount) } + } else if (config.method !== 'get') mutations.push(config.url || '') + return { config, status: 200, statusText: 'OK', headers: {}, data } + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + function Harness() { + const [, setRevision] = useState(0) + return ( + <> + + + + ) + } + const view = await env.render( + + + + ) + const fields = within(view.container) + try { + await waitFor(() => + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '10' + ) + ) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '0' } } + ) + }) + await view.click(fields.getByText('Render wallet again')) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '0' + ) + assert.deepEqual(quotes, [10, 0]) + assert.deepEqual(mutations, []) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('epay selection survives a host rerender and an in-flight payment stays locked across reopen', async () => { + const adapter = api.defaults.adapter + const plan: PlanRecord = { + plan: subscriptionPlanSchema.parse({ + id: 1, + title: 'Synthetic plan', + price_amount: 1, + currency: 'USD', + duration_unit: 'month', + duration_value: 1, + quota_reset_period: 'never', + enabled: true, + sort_order: 0, + allow_balance_pay: false, + max_purchase_per_user: 0, + total_amount: 0, + }), + } + const payments: unknown[] = [] + let release!: () => void + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'post') + payments.push(JSON.parse(config.data)) + await new Promise((resolve) => { + release = resolve + }) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: false, message: 'Synthetic payment declined' }, + } + } + function Harness() { + const [open, setOpen] = useState(true) + const [, setRevision] = useState(0) + return ( + <> + + + + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + await view.click(body.getByRole('combobox')) + await view.click(body.getByRole('option', { name: 'Synthetic WeChat' })) + await view.click(within(view.container).getByText('Render purchase again')) + assert.ok( + body.getByRole('combobox').textContent?.includes('Synthetic WeChat') + ) + await view.click(body.getByRole('button', { name: 'Pay' })) + assert.equal(payments.length, 1) + assert.deepEqual(payments[0], { plan_id: 1, payment_method: 'wxpay' }) + await view.click(within(view.container).getByText('Toggle purchase')) + await view.click(within(view.container).getByText('Toggle purchase')) + const pay = body.getByRole('button', { + name: 'Pay', + }) as HTMLButtonElement + assert.equal(pay.disabled, true) + await view.click(pay) + assert.equal(payments.length, 1) + await act(async () => release()) + assert.equal( + ( + body.getByRole('button', { + name: 'Pay', + }) as HTMLButtonElement + ).disabled, + false + ) + } finally { + release?.() + await view.unmount() + api.defaults.adapter = adapter + } +}) + +test('subscription expiry display advances without a refresh or any financial write', async () => { + const adapter = api.defaults.adapter, + originalNow = Date.now + const interval = Object.getOwnPropertyDescriptor(globalThis, 'setTimeout')!, + clear = Object.getOwnPropertyDescriptor(globalThis, 'clearTimeout')! + let now = 1710000000000 + let tick: (() => void) | undefined + const requests: string[] = [] + Date.now = () => now + const nativeInterval = globalThis.setTimeout, + nativeClear = globalThis.clearTimeout + const clockHandle = Symbol('subscription display clock') + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: (callback: () => void, milliseconds: number) => { + if (milliseconds !== 1001) return nativeInterval(callback, milliseconds) + tick = callback + return clockHandle + }, + }) + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: true, + value: (handle: unknown) => { + if (handle === clockHandle) tick = undefined + else nativeClear(handle as ReturnType) + }, + }) + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + requests.push(config.url || '') + const subscription = { + id: 7, + user_id: 1, + plan_id: 1, + status: 'active', + start_time: now / 1000 - 60, + end_time: now / 1000 + 1, + amount_total: 100, + amount_used: 25, + } + const data = + config.url === '/api/subscription/plans' + ? [] + : { + billing_preference: 'subscription_first', + subscriptions: [{ subscription }], + all_subscriptions: [{ subscription }], + } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data }, + } + } + const view = await env.render( + + ) + const body = within(view.container) + try { + await waitFor(() => assert.ok(body.queryByText('Active'))) + await act(async () => { + now += 2000 + tick?.() + }) + assert.equal(body.queryByText('Active'), null) + assert.ok(body.queryByText('Expired')) + assert.equal(requests.length, 2) + } finally { + await view.unmount() + assert.equal(tick, undefined) + Date.now = originalNow + Object.defineProperty(globalThis, 'setTimeout', interval) + Object.defineProperty(globalThis, 'clearTimeout', clear) + api.defaults.adapter = adapter + } +}) diff --git a/web/default/src/hooks/state-lifecycle.runtime.test.tsx b/web/default/src/hooks/state-lifecycle.runtime.test.tsx new file mode 100644 index 00000000..f490a35e --- /dev/null +++ b/web/default/src/hooks/state-lifecycle.runtime.test.tsx @@ -0,0 +1,428 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useState } from 'react' +import { AxiosError } from 'axios' +import { createReactTestEnvironment } from '@/test/react' +import { within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { useAuthStore } from '@/stores/auth-store' +import { api } from '@/lib/api' +import { useThemeRadiusPx } from '@/lib/theme-radius' +import { useMinimumLoadingTime } from './use-minimum-loading-time' +import { useIsMobile } from './use-mobile' +import { useReadScope } from './use-read-scope' +import { useResettableState } from './use-resettable-state' + +const env = createReactTestEnvironment() +await env.setup() +const { useProfile } = await import('@/features/profile/hooks/use-profile') +after(() => env.teardown()) + +test('failed settings writes do not log credential-bearing request bodies or retry', async () => { + const adapter = api.defaults.adapter + const originalLog = Object.getOwnPropertyDescriptor(console, 'error')! + const logs: unknown[][] = [] + const marker = 'synthetic-private-marker' + let writes = 0 + api.defaults.adapter = async (config) => { + if (config.method === 'get') + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id: 1, username: 'logging-profile', role: 1 }, + }, + } + writes += 1 + throw new AxiosError( + 'Synthetic update failure', + 'ERR_BAD_RESPONSE', + config, + undefined, + { config, status: 500, statusText: 'Error', headers: {}, data: {} } + ) + } + Object.defineProperty(console, 'error', { + ...originalLog, + value: (...args: unknown[]) => logs.push(args), + }) + function Probe() { + const { updateSettings } = useProfile() + const [result, setResult] = useState('pending') + return ( + <> + {result} + + + ) + } + const view = await env.render() + try { + await view.click(within(view.container).getByText('Save private setting')) + assert.equal( + within(view.container).getByRole('status').textContent, + 'false' + ) + assert.equal(writes, 1) + assert.ok(logs.length > 0) + assert.equal(JSON.stringify(logs).includes(marker), false) + } finally { + await view.unmount() + Object.defineProperty(console, 'error', originalLog) + api.defaults.adapter = adapter + } +}) + +test('a silent refresh can finish a visible profile load without leaving its spinner stuck', async () => { + const adapter = api.defaults.adapter + let release!: () => void + let requests = 0 + api.defaults.adapter = async (config) => { + requests += 1 + await new Promise((resolve) => { + release = resolve + }) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id: 1, username: 'profile-ready', role: 1 }, + }, + } + } + function Probe() { + const { loading, profile, refreshProfile } = useProfile() + return ( + <> + {String(loading)} + {profile?.username || '-'} + + + ) + } + const view = await env.render() + const fields = within(view.container) + try { + assert.equal( + fields.getByRole('status', { name: 'Loading' }).textContent, + 'true' + ) + await view.click(fields.getByText('Silent refresh')) + await act(async () => release()) + assert.equal( + fields.getByRole('status', { name: 'Loading' }).textContent, + 'false' + ) + assert.equal( + fields.getByRole('status', { name: 'Profile' }).textContent, + 'profile-ready' + ) + assert.equal(requests, 1) + } finally { + release?.() + await view.unmount() + api.defaults.adapter = adapter + } +}) + +test('drafts keep local edits on rerender and reset explicit zero and false with their source', async () => { + const oldSetters: Array< + (value: { count: number; enabled: boolean }) => void + > = [] + function Harness() { + const [source, setSource] = useState(1) + const [, setRevision] = useState(0) + const [draft, setDraft] = useResettableState( + { count: 0, enabled: false }, + source + ) + return ( + <> + {JSON.stringify(draft)} + + + + + + ) + } + const view = await env.render() + const fields = within(view.container) + try { + await view.click(fields.getByText('Edit')) + await view.click(fields.getByText('Render again')) + assert.equal( + fields.getByRole('status').textContent, + '{"count":7,"enabled":true}' + ) + await view.click(fields.getByText('Change source')) + assert.equal( + fields.getByRole('status').textContent, + '{"count":0,"enabled":false}' + ) + await view.click(fields.getByText('Reuse first source')) + await act(async () => oldSetters[0]({ count: 99, enabled: true })) + assert.equal( + fields.getByRole('status').textContent, + '{"count":0,"enabled":false}' + ) + } finally { + await view.unmount() + } +}) + +test('read scopes reject older requests, changed entities, sessions and unmounted results', async () => { + const previousUser = useAuthStore.getState().auth.user + const reads: Array<() => boolean> = [] + const starts: Array<() => () => boolean> = [] + function Harness() { + const [key, setKey] = useState(1) + const begin = useReadScope(key) + return ( + <> + + + + + ) + } + const view = await env.render() + const fields = within(view.container) + try { + await view.click(fields.getByText('Read')) + await view.click(fields.getByText('Read')) + assert.equal(reads[0](), false) + assert.equal(reads[1](), true) + await view.click(fields.getByText('Next')) + assert.equal(reads[1](), false) + await view.click(fields.getByText('Read')) + assert.equal(reads[2](), true) + assert.equal(starts[0]()(), false) + assert.equal(reads[2](), true) + await view.click(fields.getByText('First again')) + await view.click(fields.getByText('Read')) + assert.equal(starts[0]()(), false) + assert.equal(reads[3](), true) + await act(async () => useAuthStore.getState().auth.reset()) + assert.equal(reads[3](), false) + await view.click(fields.getByText('Read')) + await view.unmount() + assert.equal(reads[4](), false) + } finally { + useAuthStore.getState().auth.setUser(previousUser) + } +}) + +test('mobile snapshot starts at the current width and unsubscribes on unmount', async () => { + const original = env.dom.window.matchMedia + const width = Object.getOwnPropertyDescriptor(env.dom.window, 'innerWidth') + const listeners = new Set<() => void>() + let currentWidth = 500 + Object.defineProperty(env.dom.window, 'innerWidth', { + configurable: true, + get: () => currentWidth, + }) + Object.defineProperty(env.dom.window, 'matchMedia', { + configurable: true, + value: () => ({ + addEventListener: (_event: string, listener: () => void) => + listeners.add(listener), + removeEventListener: (_event: string, listener: () => void) => + listeners.delete(listener), + }), + }) + function Probe() { + return {String(useIsMobile())} + } + const view = await env.render() + try { + assert.equal(view.container.textContent, 'true') + await act(async () => { + currentWidth = 900 + listeners.forEach((notify) => notify()) + }) + assert.equal(view.container.textContent, 'false') + await view.unmount() + assert.equal(listeners.size, 0) + } finally { + Object.defineProperty(env.dom.window, 'matchMedia', { + configurable: true, + value: original, + }) + if (width) Object.defineProperty(env.dom.window, 'innerWidth', width) + } +}) + +test('minimum loading lasts for the current cycle and old timers cannot end a newer cycle', async () => { + const descriptors = new Map( + ['setTimeout', 'clearTimeout'].map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key)!, + ]) + ) + const originalNow = Date.now + const timers = new Map void }>() + let now = 0, + nextId = 0 + Date.now = () => now + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: (run: () => void, delay = 0) => { + const id = ++nextId + timers.set(id, { at: now + delay, run }) + return id + }, + }) + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: true, + value: (id: number) => timers.delete(id), + }) + const advance = async (milliseconds: number) => + act(async () => { + now += milliseconds + for (const [id, timer] of [...timers]) + if (timer.at <= now) { + timers.delete(id) + timer.run() + } + }) + function Harness() { + const [loading, setLoading] = useState(false) + const visible = useMinimumLoadingTime(loading, 100) + return ( + <> + {String(visible)} + + + + ) + } + const view = await env.render() + const fields = within(view.container) + const visible = () => fields.getByRole('status').textContent + try { + assert.equal(visible(), 'false') + await view.click(fields.getByText('Start')) + await advance(40) + await view.click(fields.getByText('Stop')) + assert.equal(visible(), 'true') + await advance(60) + assert.equal(visible(), 'false') + await view.click(fields.getByText('Start')) + await advance(100) + assert.equal(visible(), 'true') + await view.click(fields.getByText('Stop')) + assert.equal(visible(), 'false') + await view.click(fields.getByText('Start')) + await advance(20) + await view.click(fields.getByText('Stop')) + await view.click(fields.getByText('Start')) + await view.click(fields.getByText('Stop')) + await advance(80) + assert.equal(visible(), 'true') + await advance(20) + assert.equal(visible(), 'false') + } finally { + await view.unmount() + assert.equal(timers.size, 0) + Date.now = originalNow + for (const [key, descriptor] of descriptors) + Object.defineProperty(globalThis, key, descriptor) + } +}) + +test('theme radius follows DOM changes and a refresh key without leaving resize listeners', async () => { + const computed = Object.getOwnPropertyDescriptor( + globalThis, + 'getComputedStyle' + )! + let radius = 12, + measurements = 0 + Object.defineProperty(globalThis, 'getComputedStyle', { + configurable: true, + value: () => { + measurements += 1 + return { borderTopLeftRadius: `${radius}px` } + }, + }) + function Harness() { + const [revision, setRevision] = useState(0) + const value = useThemeRadiusPx('--radius-md', String(revision)) + return ( + <> + {String(value)} + + + ) + } + const view = await env.render() + try { + assert.equal(within(view.container).getByRole('status').textContent, '12') + await act(async () => { + radius = 24 + env.dom.window.document.documentElement.setAttribute( + 'data-theme', + 'test-dark' + ) + }) + assert.equal(within(view.container).getByRole('status').textContent, '24') + radius = 36 + await view.click(within(view.container).getByText('Refresh')) + assert.equal(within(view.container).getByRole('status').textContent, '36') + await view.unmount() + const before = measurements + env.dom.window.dispatchEvent(new env.dom.window.Event('resize')) + assert.equal(measurements, before) + } finally { + Object.defineProperty(globalThis, 'getComputedStyle', computed) + } +}) diff --git a/web/default/src/hooks/use-minimum-loading-time.ts b/web/default/src/hooks/use-minimum-loading-time.ts index 2d297835..7a808fcc 100644 --- a/web/default/src/hooks/use-minimum-loading-time.ts +++ b/web/default/src/hooks/use-minimum-loading-time.ts @@ -26,26 +26,34 @@ export function useMinimumLoadingTime( loading: boolean, minimumTime = 1000 ): boolean { - const [showSkeleton, setShowSkeleton] = useState(loading) - // eslint-disable-next-line react-hooks/purity - const loadingStartRef = useRef(Date.now()) - + const [period, setPeriod] = useState(() => ({ + loading, + cycle: loading ? 1 : 0, + elapsed: !loading, + })) + const started = useRef({ cycle: 0, time: 0 }) + if (period.loading !== loading) { + setPeriod({ + loading, + cycle: loading ? period.cycle + 1 : period.cycle, + elapsed: loading ? false : period.elapsed, + }) + } + const cycle = period.cycle useEffect(() => { - if (loading) { - loadingStartRef.current = Date.now() - setShowSkeleton(true) - } else { - const elapsed = Date.now() - loadingStartRef.current - const remaining = Math.max(0, minimumTime - elapsed) - - if (remaining === 0) { - setShowSkeleton(false) - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining) - return () => clearTimeout(timer) - } - } - }, [loading, minimumTime]) - - return showSkeleton + if (cycle === 0) return + if (started.current.cycle !== cycle) + started.current = { cycle, time: Date.now() } + const remaining = Math.max( + 0, + minimumTime - (Date.now() - started.current.time) + ) + const timer = setTimeout(() => { + setPeriod((previous) => + previous.cycle === cycle ? { ...previous, elapsed: true } : previous + ) + }, remaining) + return () => clearTimeout(timer) + }, [cycle, minimumTime]) + return loading || (minimumTime > 0 && cycle > 0 && !period.elapsed) } diff --git a/web/default/src/hooks/use-mobile.ts b/web/default/src/hooks/use-mobile.ts index 55562f80..f5a74728 100644 --- a/web/default/src/hooks/use-mobile.ts +++ b/web/default/src/hooks/use-mobile.ts @@ -19,19 +19,19 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import * as React from 'react' const MOBILE_BREAKPOINT = 768 - +function subscribe(onChange: () => void) { + const query = window.matchMedia( + '(max-width: ' + (MOBILE_BREAKPOINT - 1) + 'px)' + ) + query.addEventListener('change', onChange) + return () => query.removeEventListener('change', onChange) +} +function getSnapshot() { + return window.innerWidth < MOBILE_BREAKPOINT +} +function getServerSnapshot() { + return false +} export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } diff --git a/web/default/src/hooks/use-mobile.tsx b/web/default/src/hooks/use-mobile.tsx index 55562f80..f5a74728 100644 --- a/web/default/src/hooks/use-mobile.tsx +++ b/web/default/src/hooks/use-mobile.tsx @@ -19,19 +19,19 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import * as React from 'react' const MOBILE_BREAKPOINT = 768 - +function subscribe(onChange: () => void) { + const query = window.matchMedia( + '(max-width: ' + (MOBILE_BREAKPOINT - 1) + 'px)' + ) + query.addEventListener('change', onChange) + return () => query.removeEventListener('change', onChange) +} +function getSnapshot() { + return window.innerWidth < MOBILE_BREAKPOINT +} +function getServerSnapshot() { + return false +} export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } diff --git a/web/default/src/hooks/use-read-scope.ts b/web/default/src/hooks/use-read-scope.ts new file mode 100644 index 00000000..9ab112ae --- /dev/null +++ b/web/default/src/hooks/use-read-scope.ts @@ -0,0 +1,60 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useAuthStore } from '@/stores/auth-store' + +/** Ignore obsolete reads; this does not cancel a request or undo a server mutation. */ +export function useReadScope(scopeKey?: unknown) { + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const revision = useRef(0) + const mounted = useRef(false) + const [owner, setOwner] = useState(() => ({ key: scopeKey, sessionRevision })) + let scope = owner + if ( + !Object.is(owner.key, scopeKey) || + owner.sessionRevision !== sessionRevision + ) { + scope = { key: scopeKey, sessionRevision } + setOwner(scope) + } + const activeScope = useRef(scope) + useLayoutEffect(() => { + activeScope.current = scope + mounted.current = true + revision.current += 1 + return () => { + mounted.current = false + revision.current += 1 + } + }, [scope]) + return useCallback(() => { + if ( + !mounted.current || + activeScope.current !== scope || + useAuthStore.getState().sessionRevision !== scope.sessionRevision + ) + return () => false + const current = ++revision.current + return () => + mounted.current && + revision.current === current && + activeScope.current === scope && + useAuthStore.getState().sessionRevision === scope.sessionRevision + }, [scope]) +} diff --git a/web/default/src/hooks/use-resettable-state.ts b/web/default/src/hooks/use-resettable-state.ts new file mode 100644 index 00000000..93dc2f29 --- /dev/null +++ b/web/default/src/hooks/use-resettable-state.ts @@ -0,0 +1,66 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { + useCallback, + useState, + type Dispatch, + type SetStateAction, +} from 'react' + +/** Keep an editable draft until its source identity changes, before children commit. */ +export function useResettableState( + initialValue: T | (() => T), + resetKey: unknown +): [T, Dispatch>] { + const [draft, setDraft] = useState(() => ({ + key: resetKey, + scope: {}, + value: + typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue, + })) + let current = draft + if (!Object.is(draft.key, resetKey)) { + const nextValue = + typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue + current = { key: resetKey, scope: {}, value: nextValue } + setDraft(current) + } + const scope = current.scope + const setValue = useCallback>>( + (action) => { + setDraft((previous) => + previous.scope !== scope + ? previous + : { + ...previous, + value: + typeof action === 'function' + ? (action as (value: T) => T)(previous.value) + : action, + } + ) + }, + [scope] + ) + return [current.value, setValue] +} diff --git a/web/default/src/lib/api-session.runtime.test.ts b/web/default/src/lib/api-session.runtime.test.ts new file mode 100644 index 00000000..6f61679b --- /dev/null +++ b/web/default/src/lib/api-session.runtime.test.ts @@ -0,0 +1,285 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { AxiosError, type InternalAxiosRequestConfig } from 'axios' +import { QueryCache, QueryClient } from '@tanstack/react-query' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { useAuthStore } from '@/stores/auth-store' +import { appSessionVerifier } from '@/features/auth/lib/app-session-verifier' +import { api } from './api' +import { handleQueryAuthenticationError } from './auth-errors' + +test('GET dedup stays within a login session, including same-user logout and login', async () => { + const adapter = api.defaults.adapter + const user = useAuthStore.getState().auth.user + const pending: Array<() => void> = [] + let transports = 0 + api.defaults.adapter = async (config) => { + const owner = useAuthStore.getState().auth.user?.id + transports += 1 + await new Promise((resolve) => pending.push(resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { owner }, + } + } + const calls: Array> = [] + try { + useAuthStore.getState().auth.setUser({ id: 1, username: 'first', role: 1 }) + const first = api.get('/test/session-data') + calls.push(first) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 1) + useAuthStore.getState().auth.setUser({ id: 2, username: 'second', role: 1 }) + const second = api.get('/test/session-data') + calls.push(second) + assert.notEqual(second, first) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 2) + pending[0]() + assert.equal((await first).data.owner, 1) + assert.equal(api.get('/test/session-data'), second) + useAuthStore + .getState() + .auth.setUser({ id: 2, username: 'second', role: 1, quota: 0 }) + assert.equal(api.get('/test/session-data'), second) + useAuthStore.getState().auth.setUser({ + id: 2, + username: 'second', + role: 1, + permissions: { sidebar_settings: false }, + }) + const permissionsChanged = api.get('/test/session-data') + calls.push(permissionsChanged) + assert.notEqual(permissionsChanged, second) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 3) + useAuthStore.getState().auth.reset() + useAuthStore.getState().auth.setUser({ id: 2, username: 'second', role: 1 }) + const relogin = api.get('/test/session-data') + calls.push(relogin) + assert.notEqual(relogin, permissionsChanged) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 4) + } finally { + pending.forEach((resolve) => resolve()) + await Promise.allSettled(calls) + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('a late 401 rejects the original call without logging out a newer session', async () => { + const adapter = api.defaults.adapter + const user = useAuthStore.getState().auth.user + let rejectRequest: (() => void) | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + api.defaults.adapter = (config: InternalAxiosRequestConfig) => + new Promise((_, reject) => { + rejectRequest = () => + reject( + new AxiosError('Expired', 'ERR_BAD_REQUEST', config, undefined, { + config, + status: 401, + statusText: 'Unauthorized', + headers: {}, + data: {}, + }) + ) + started() + }) + try { + useAuthStore.getState().auth.setUser({ id: 1, username: 'old', role: 1 }) + const old = api.post('/test/no-retry', {}, { skipErrorHandler: true }) + const rejected = assert.rejects(old, /Expired/) + await ready + useAuthStore.getState().auth.setUser({ id: 2, username: 'new', role: 1 }) + rejectRequest!() + await rejected + assert.equal(useAuthStore.getState().auth.user?.id, 2) + const current = api.get('/test/current', { skipErrorHandler: true }) + const currentRejected = assert.rejects(current, /Expired/) + await new Promise((resolve) => setImmediate(resolve)) + rejectRequest!() + await currentRejected + assert.equal(useAuthStore.getState().auth.user, null) + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('query-cache error handling must not let an old 401 expire a newer session', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + let rejectRequest!: () => void, + started!: () => void, + expired = 0 + const ready = new Promise((resolve) => { + started = resolve + }) + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + queryCache: new QueryCache({ + onError: (error) => + handleQueryAuthenticationError(error, () => { + expired += 1 + useAuthStore.getState().auth.reset() + }), + }), + }) + api.defaults.adapter = (config) => + new Promise((_, reject) => { + rejectRequest = () => + reject( + new AxiosError( + 'Expired query', + 'ERR_BAD_REQUEST', + config, + undefined, + { + config, + status: 401, + statusText: 'Unauthorized', + headers: {}, + data: {}, + } + ) + ) + started() + }) + try { + useAuthStore.getState().auth.setUser({ id: 1, username: 'old', role: 1 }) + const failed = assert.rejects( + client.fetchQuery({ + queryKey: ['old-query'], + queryFn: () => api.get('/test/old-query', { skipErrorHandler: true }), + }), + /Expired query/ + ) + await ready + useAuthStore.getState().auth.setUser({ id: 2, username: 'new', role: 1 }) + rejectRequest() + await failed + assert.equal(expired, 0) + assert.equal(useAuthStore.getState().auth.user?.id, 2) + const currentFailed = assert.rejects( + client.fetchQuery({ + queryKey: ['current-query'], + queryFn: () => + api.get('/test/current-query', { skipErrorHandler: true }), + }), + /Expired query/ + ) + await new Promise((resolve) => setImmediate(resolve)) + rejectRequest() + await currentFailed + assert.equal(expired, 1) + assert.equal(useAuthStore.getState().auth.user, null) + } finally { + client.clear() + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('session verification cache is invalidated by same-user logout and login', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + let requests = 0 + api.defaults.adapter = async (config) => { + requests += 1 + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id: 1, username: 'verified-user', role: 1 }, + }, + } + } + try { + useAuthStore + .getState() + .auth.setUser({ id: 1, username: 'verified-user', role: 1 }) + assert.equal(await appSessionVerifier.verify(), true) + assert.equal(requests, 1) + useAuthStore.getState().auth.reset() + useAuthStore + .getState() + .auth.setUser({ id: 1, username: 'verified-user', role: 1 }) + assert.equal(await appSessionVerifier.verify(), true) + assert.equal(requests, 2) + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('an anonymous verification cannot publish an older session user after reset', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + let release!: () => void, + started!: () => void, + requests = 0 + const ready = new Promise((resolve) => { + started = resolve + }) + api.defaults.adapter = async (config) => { + const id = ++requests === 1 ? 11 : 22 + if (id === 11) { + started() + await new Promise((resolve) => { + release = resolve + }) + } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id, username: `verified-${id}`, role: 1 }, + }, + } + } + try { + useAuthStore.getState().auth.reset() + const verifying = appSessionVerifier.verify() + await ready + useAuthStore.getState().auth.reset() + release() + assert.equal(await verifying, true) + assert.equal(useAuthStore.getState().auth.user?.id, 22) + assert.equal(requests, 2) + } finally { + release?.() + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index 28c9822a..f702a2a7 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -27,6 +27,8 @@ declare module 'axios' { skipBusinessError?: boolean skipErrorHandler?: boolean disableDuplicate?: boolean + authSessionRevision?: number + staleAuthResponse?: boolean } } @@ -55,6 +57,9 @@ export const api = axios.create({ // Deduplicate concurrent GET requests to the same URL // Prevents multiple identical requests from being sent simultaneously const inFlightGet = new Map>() +useAuthStore.subscribe((state, previous) => { + if (state.sessionRevision !== previous.sessionRevision) inFlightGet.clear() +}) const originalGet = api.get.bind(api) api.get = ((url: string, config: ApiRequestConfig = {}) => { @@ -62,7 +67,7 @@ api.get = ((url: string, config: ApiRequestConfig = {}) => { if (disableDuplicate) return originalGet(url, config) const params = config.params ? JSON.stringify(config.params) : '{}' - const key = `${url}?${params}` + const key = `${useAuthStore.getState().sessionRevision}:${url}?${params}` // Return existing in-flight request if available if (inFlightGet.has(key)) return inFlightGet.get(key)! @@ -102,6 +107,13 @@ api.interceptors.response.use( const status = error?.response?.status if (status === 401) { + const belongsToCurrentSession = + error?.config?.authSessionRevision === + useAuthStore.getState().sessionRevision + if (!belongsToCurrentSession) { + if (error.config) error.config.staleAuthResponse = true + return Promise.reject(error) + } try { useAuthStore.getState().auth.reset() } catch { @@ -160,14 +172,20 @@ export function getCommonHeaders(): Record { // ============================================================================ // Attach user ID header for all requests -api.interceptors.request.use((config) => { - const uid = getUserId() - if (uid) { - // Custom header for user identification - ;(config.headers as Record)['Max-Api-User'] = uid - } - return config -}) +api.interceptors.request.use( + (config) => { + config.authSessionRevision = useAuthStore.getState().sessionRevision + config.staleAuthResponse = false + const uid = getUserId() + if (uid) { + // Custom header for user identification + ;(config.headers as Record)['Max-Api-User'] = uid + } + return config + }, + undefined, + { synchronous: true } +) // ============================================================================ // Common API Functions diff --git a/web/default/src/lib/auth-errors.ts b/web/default/src/lib/auth-errors.ts new file mode 100644 index 00000000..441e9ca0 --- /dev/null +++ b/web/default/src/lib/auth-errors.ts @@ -0,0 +1,35 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { AxiosError } from 'axios' + +export function isStaleAuthResponse(error: unknown): boolean { + return error instanceof AxiosError && error.config?.staleAuthResponse === true +} + +export function handleQueryAuthenticationError( + error: unknown, + onExpired: () => void +): void { + if ( + error instanceof AxiosError && + error.response?.status === 401 && + !isStaleAuthResponse(error) + ) + onExpired() +} diff --git a/web/default/src/lib/theme-radius.ts b/web/default/src/lib/theme-radius.ts index 23cbee2a..ba3164e9 100644 --- a/web/default/src/lib/theme-radius.ts +++ b/web/default/src/lib/theme-radius.ts @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useMemo, useSyncExternalStore } from 'react' export function resolveThemeRadiusPx( cssVariable = '--radius-md' @@ -37,15 +37,51 @@ export function resolveThemeRadiusPx( return Number.isFinite(parsedRadius) ? parsedRadius : undefined } +function createRadiusStore(options: { + cssVariable: string + refreshKey?: string +}) { + let radius: number | undefined + const listeners = new Set<() => void>() + const measure = () => { + const next = resolveThemeRadiusPx(options.cssVariable) + if (Object.is(next, radius)) return + radius = next + for (const notify of listeners) notify() + } + return { + getSnapshot: () => radius, + getServerSnapshot: () => undefined, + subscribe: (notify: () => void) => { + listeners.add(notify) + const observer = new MutationObserver(measure) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style', 'data-theme'], + }) + window.addEventListener('resize', measure) + measure() + return () => { + listeners.delete(notify) + observer.disconnect() + window.removeEventListener('resize', measure) + } + }, + } +} + export function useThemeRadiusPx( cssVariable = '--radius-md', refreshKey?: string ): number | undefined { - const [radius, setRadius] = useState() - - useEffect(() => { - setRadius(resolveThemeRadiusPx(cssVariable)) - }, [cssVariable, refreshKey]) - - return radius + // A changed theme key creates a fresh measurement after the DOM commits. + const store = useMemo( + () => createRadiusStore({ cssVariable, refreshKey }), + [cssVariable, refreshKey] + ) + return useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getServerSnapshot + ) } diff --git a/web/default/src/main.tsx b/web/default/src/main.tsx index ed984042..8fc4450a 100644 --- a/web/default/src/main.tsx +++ b/web/default/src/main.tsx @@ -29,12 +29,19 @@ import i18next from 'i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' import { getStatus } from '@/lib/api' +import { + handleQueryAuthenticationError, + isStaleAuthResponse, +} from '@/lib/auth-errors' import { installBuildMetadata } from '@/lib/build-metadata' import { normalizeDefaultLogo } from '@/lib/constants' import '@/lib/dayjs' import { applyFaviconToDom } from '@/lib/dom-utils' import { initializeFrontendCache } from '@/lib/frontend-cache' -import { handleServerError } from '@/lib/handle-server-error' +import { + getSafeErrorDebugInfo, + handleServerError, +} from '@/lib/handle-server-error' import { DirectionProvider } from './context/direction-provider' import { FontProvider } from './context/font-provider' import { ThemeProvider } from './context/theme-provider' @@ -53,8 +60,10 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { - // eslint-disable-next-line no-console - if (import.meta.env.DEV) console.log({ failureCount, error }) + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log({ failureCount, error: getSafeErrorDebugInfo(error) }) + } if (failureCount >= 0 && import.meta.env.DEV) return false if (failureCount > 3 && import.meta.env.PROD) return false @@ -69,6 +78,7 @@ const queryClient = new QueryClient({ }, mutations: { onError: (error) => { + if (isStaleAuthResponse(error)) return handleServerError(error) if (error instanceof AxiosError) { @@ -81,14 +91,12 @@ const queryClient = new QueryClient({ }, queryCache: new QueryCache({ onError: (error) => { - if (error instanceof AxiosError) { - if (error.response?.status === 401) { - toast.error(i18next.t('Session expired!')) - useAuthStore.getState().auth.reset() - const redirect = `${router.history.location.href}` - router.navigate({ to: '/sign-in', search: { redirect } }) - } - } + handleQueryAuthenticationError(error, () => { + toast.error(i18next.t('Session expired!')) + useAuthStore.getState().auth.reset() + const redirect = `${router.history.location.href}` + router.navigate({ to: '/sign-in', search: { redirect } }) + }) }, }), }) diff --git a/web/default/src/stores/auth-store.ts b/web/default/src/stores/auth-store.ts index 24522187..753affe8 100644 --- a/web/default/src/stores/auth-store.ts +++ b/web/default/src/stores/auth-store.ts @@ -52,6 +52,7 @@ export interface AuthUser { } interface AuthState { + sessionRevision: number auth: { user: AuthUser | null setUser: (user: AuthUser | null) => void @@ -60,13 +61,31 @@ interface AuthState { } export const useAuthStore = create()((set) => ({ + sessionRevision: 0, auth: { user: null, setUser: (user) => - set((state) => ({ ...state, auth: { ...state.auth, user } })), + set((state) => { + const previous = state.auth.user + const identityChanged = + !user || + !previous || + user.id !== previous?.id || + user.role !== previous.role || + user.status !== previous.status || + user.group !== previous.group || + JSON.stringify(user.permissions ?? null) !== + JSON.stringify(previous.permissions ?? null) + return { + ...state, + sessionRevision: state.sessionRevision + (identityChanged ? 1 : 0), + auth: { ...state.auth, user }, + } + }), reset: () => set((state) => ({ ...state, + sessionRevision: state.sessionRevision + 1, auth: { ...state.auth, user: null }, })), }, diff --git a/web/default/src/test/react.test.ts b/web/default/src/test/react.test.ts index 00cb4560..bf3497be 100644 --- a/web/default/src/test/react.test.ts +++ b/web/default/src/test/react.test.ts @@ -16,10 +16,40 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { act, createElement, useState, type ChangeEvent } from 'react' import assert from 'node:assert/strict' import { test } from 'node:test' import { createReactTestEnvironment } from './react' +test('controlled input changes reach React after installing browser globals', async () => { + const env = createReactTestEnvironment() + await env.setup() + const { fireEvent } = await import('@testing-library/react/pure') + const observed: string[] = [] + function Probe() { + const [value, setValue] = useState('initial') + return createElement('input', { + value, + onChange: (event: ChangeEvent) => { + observed.push(event.target.value) + setValue(event.target.value) + }, + }) + } + const view = await env.render(createElement(Probe)) + try { + await act(async () => { + fireEvent.change(view.container.querySelector('input')!, { + target: { value: 'updated' }, + }) + }) + assert.deepEqual(observed, ['updated']) + } finally { + await view.unmount() + env.teardown() + } +}) + test('teardown cancels pending animation frames before removing browser globals', async (): Promise => { const testEnv = createReactTestEnvironment() await testEnv.setup() diff --git a/web/default/src/test/react.tsx b/web/default/src/test/react.tsx index ed63e8fc..e0a12f69 100644 --- a/web/default/src/test/react.tsx +++ b/web/default/src/test/react.tsx @@ -17,7 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { act, type ReactElement } from 'react' -import { createRoot } from 'react-dom/client' import { createInstance, type Resource } from 'i18next' import { JSDOM } from 'jsdom' import { I18nextProvider } from 'react-i18next' @@ -187,6 +186,7 @@ export function createReactTestEnvironment(options?: { dom.window.close() }, async render(element: ReactElement) { + const { createRoot } = await import('react-dom/client') const container = dom.window.document.createElement('div') dom.window.document.body.append(container) const root = createRoot(container) From db86de1c97203e064d9d632b17bd263a66f21589 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 13:26:26 +0800 Subject: [PATCH 08/23] fix(web): preserve notification drafts across profile refreshes --- .../components/tabs/notification-tab.tsx | 69 ++++----- .../hooks/state-lifecycle.runtime.test.tsx | 134 +++++++++++++++++- web/default/src/hooks/use-mobile.tsx | 37 ----- .../src/lib/api-session.runtime.test.ts | 39 +++++ web/default/src/stores/auth-store.ts | 16 +-- 5 files changed, 216 insertions(+), 79 deletions(-) delete mode 100644 web/default/src/hooks/use-mobile.tsx diff --git a/web/default/src/features/profile/components/tabs/notification-tab.tsx b/web/default/src/features/profile/components/tabs/notification-tab.tsx index 937b314c..7e192771 100644 --- a/web/default/src/features/profile/components/tabs/notification-tab.tsx +++ b/web/default/src/features/profile/components/tabs/notification-tab.tsx @@ -56,41 +56,44 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { const { t } = useTranslation() const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN const [loading, setLoading] = useState(false) - const [settings, setSettings] = useResettableState(() => { - if (!profile?.setting) + const [settings, setSettings] = useResettableState( + () => { + if (!profile?.setting) + return { + notify_type: 'email', + quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD, + notification_email: '', + webhook_url: '', + webhook_secret: '', + bark_url: '', + gotify_url: '', + gotify_token: '', + gotify_priority: 5, + accept_unset_model_ratio_model: false, + record_ip_log: false, + upstream_model_update_notify_enabled: false, + } + const parsed = parseUserSettings(profile.setting) return { - notify_type: 'email', - quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD, - notification_email: '', - webhook_url: '', - webhook_secret: '', - bark_url: '', - gotify_url: '', - gotify_token: '', - gotify_priority: 5, - accept_unset_model_ratio_model: false, - record_ip_log: false, - upstream_model_update_notify_enabled: false, + notify_type: parsed.notify_type || 'email', + quota_warning_threshold: + parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD, + notification_email: parsed.notification_email ?? '', + webhook_url: parsed.webhook_url ?? '', + webhook_secret: parsed.webhook_secret ?? '', + bark_url: parsed.bark_url ?? '', + gotify_url: parsed.gotify_url ?? '', + gotify_token: parsed.gotify_token ?? '', + gotify_priority: parsed.gotify_priority ?? 5, + accept_unset_model_ratio_model: + parsed.accept_unset_model_ratio_model || false, + record_ip_log: parsed.record_ip_log || false, + upstream_model_update_notify_enabled: + parsed.upstream_model_update_notify_enabled || false, } - const parsed = parseUserSettings(profile.setting) - return { - notify_type: parsed.notify_type || 'email', - quota_warning_threshold: - parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD, - notification_email: parsed.notification_email ?? '', - webhook_url: parsed.webhook_url ?? '', - webhook_secret: parsed.webhook_secret ?? '', - bark_url: parsed.bark_url ?? '', - gotify_url: parsed.gotify_url ?? '', - gotify_token: parsed.gotify_token ?? '', - gotify_priority: parsed.gotify_priority ?? 5, - accept_unset_model_ratio_model: - parsed.accept_unset_model_ratio_model || false, - record_ip_log: parsed.record_ip_log || false, - upstream_model_update_notify_enabled: - parsed.upstream_model_update_notify_enabled || false, - } - }, profile) + }, + JSON.stringify([profile?.id, profile?.setting]) + ) // Update form field helper const updateField = useCallback( diff --git a/web/default/src/hooks/state-lifecycle.runtime.test.tsx b/web/default/src/hooks/state-lifecycle.runtime.test.tsx index f490a35e..28d9eff7 100644 --- a/web/default/src/hooks/state-lifecycle.runtime.test.tsx +++ b/web/default/src/hooks/state-lifecycle.runtime.test.tsx @@ -19,12 +19,12 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { act, useState } from 'react' import { AxiosError } from 'axios' import { createReactTestEnvironment } from '@/test/react' -import { within } from '@testing-library/react' import assert from 'node:assert/strict' import { after, test } from 'node:test' import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' import { useThemeRadiusPx } from '@/lib/theme-radius' +import type { UserProfile } from '@/features/profile/types' import { useMinimumLoadingTime } from './use-minimum-loading-time' import { useIsMobile } from './use-mobile' import { useReadScope } from './use-read-scope' @@ -32,9 +32,141 @@ import { useResettableState } from './use-resettable-state' const env = createReactTestEnvironment() await env.setup() +const { fireEvent, within } = await import('@testing-library/react/pure') const { useProfile } = await import('@/features/profile/hooks/use-profile') +const { NotificationTab } = + await import('@/features/profile/components/tabs/notification-tab') after(() => env.teardown()) +test('notification draft survives an unchanged profile refresh and resets for saved settings or account', async () => { + const adapter = api.defaults.adapter + const writes: Array<{ + quota_warning_threshold?: number + record_ip_log?: boolean + }> = [] + let updated = 0 + const initial: UserProfile = { + id: 1, + username: 'notification-user', + display_name: 'Notification user', + role: 1, + group: 'default', + quota: 0, + used_quota: 0, + request_count: 0, + status: 1, + aff_count: 0, + aff_quota: 0, + aff_history_quota: 0, + created_time: 0, + setting: JSON.stringify({ + notify_type: 'email', + quota_warning_threshold: 100, + record_ip_log: false, + }), + } + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'put') + assert.equal(config.url, '/api/user/setting') + writes.push(JSON.parse(config.data)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true }, + } + } + function Harness() { + const [profile, setProfile] = useState(initial) + return ( + <> + + + + + { + updated += 1 + }} + /> + + ) + } + const view = await env.render() + const fields = within(view.container) + const threshold = () => + fields.getByRole('spinbutton', { + name: 'Quota Warning Threshold', + }) as HTMLInputElement + const editZero = () => + act(async () => { + fireEvent.change(threshold(), { target: { value: '0' } }) + }) + try { + assert.equal(threshold().value, '100') + await editZero() + assert.equal(threshold().value, '0') + await view.click(fields.getByText('Refresh same settings')) + assert.equal(threshold().value, '0') + await view.click(fields.getByText('Change saved settings')) + assert.equal(threshold().value, '10') + await editZero() + await view.click(fields.getByText('Switch account')) + assert.equal(threshold().value, '10') + await view.click(fields.getByText('Save explicit zero')) + assert.equal(threshold().value, '0') + await view.click(fields.getByRole('button', { name: 'Save Settings' })) + assert.equal(writes.length, 1) + assert.equal(writes[0].quota_warning_threshold, 0) + assert.equal(writes[0].record_ip_log, false) + assert.equal(updated, 1) + } finally { + await view.unmount() + api.defaults.adapter = adapter + } +}) + test('failed settings writes do not log credential-bearing request bodies or retry', async () => { const adapter = api.defaults.adapter const originalLog = Object.getOwnPropertyDescriptor(console, 'error')! diff --git a/web/default/src/hooks/use-mobile.tsx b/web/default/src/hooks/use-mobile.tsx deleted file mode 100644 index f5a74728..00000000 --- a/web/default/src/hooks/use-mobile.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright (C) 2023-2026 MAX-API-Next - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues -*/ -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 -function subscribe(onChange: () => void) { - const query = window.matchMedia( - '(max-width: ' + (MOBILE_BREAKPOINT - 1) + 'px)' - ) - query.addEventListener('change', onChange) - return () => query.removeEventListener('change', onChange) -} -function getSnapshot() { - return window.innerWidth < MOBILE_BREAKPOINT -} -function getServerSnapshot() { - return false -} -export function useIsMobile() { - return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) -} diff --git a/web/default/src/lib/api-session.runtime.test.ts b/web/default/src/lib/api-session.runtime.test.ts index 6f61679b..d8e9b887 100644 --- a/web/default/src/lib/api-session.runtime.test.ts +++ b/web/default/src/lib/api-session.runtime.test.ts @@ -25,6 +25,45 @@ import { appSessionVerifier } from '@/features/auth/lib/app-session-verifier' import { api } from './api' import { handleQueryAuthenticationError } from './auth-errors' +test('anonymous no-op assignment preserves a pending GET while explicit reset invalidates it', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + const releases: Array<() => void> = [], + calls: Array> = [] + let transports = 0 + api.defaults.adapter = async (config) => { + transports += 1 + await new Promise((resolve) => releases.push(resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true }, + } + } + try { + useAuthStore.getState().auth.reset() + const revision = useAuthStore.getState().sessionRevision + const first = api.get('/test/anonymous-no-op') + calls.push(first) + useAuthStore.getState().auth.setUser(null) + assert.equal(useAuthStore.getState().sessionRevision, revision) + assert.equal(api.get('/test/anonymous-no-op'), first) + useAuthStore.getState().auth.reset() + assert.equal(useAuthStore.getState().sessionRevision, revision + 1) + const next = api.get('/test/anonymous-no-op') + calls.push(next) + assert.notEqual(next, first) + assert.equal(transports, 2) + } finally { + releases.forEach((resolve) => resolve()) + await Promise.allSettled(calls) + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + test('GET dedup stays within a login session, including same-user logout and login', async () => { const adapter = api.defaults.adapter const user = useAuthStore.getState().auth.user diff --git a/web/default/src/stores/auth-store.ts b/web/default/src/stores/auth-store.ts index 753affe8..19a40487 100644 --- a/web/default/src/stores/auth-store.ts +++ b/web/default/src/stores/auth-store.ts @@ -68,14 +68,14 @@ export const useAuthStore = create()((set) => ({ set((state) => { const previous = state.auth.user const identityChanged = - !user || - !previous || - user.id !== previous?.id || - user.role !== previous.role || - user.status !== previous.status || - user.group !== previous.group || - JSON.stringify(user.permissions ?? null) !== - JSON.stringify(previous.permissions ?? null) + user === null || previous === null + ? user !== previous + : user.id !== previous.id || + user.role !== previous.role || + user.status !== previous.status || + user.group !== previous.group || + JSON.stringify(user.permissions ?? null) !== + JSON.stringify(previous.permissions ?? null) return { ...state, sessionRevision: state.sessionRevision + (identityChanged ? 1 : 0), From 0631a2014b475ce0c3a6166de2db7c0580fcbc64 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 15:29:20 +0800 Subject: [PATCH 09/23] fix(web): preserve channel affinity drafts for unchanged settings --- .../general/channel-affinity/index.tsx | 15 ++- .../hooks/state-lifecycle.runtime.test.tsx | 121 ++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/web/default/src/features/system-settings/general/channel-affinity/index.tsx b/web/default/src/features/system-settings/general/channel-affinity/index.tsx index ae98b32b..f669df8c 100644 --- a/web/default/src/features/system-settings/general/channel-affinity/index.tsx +++ b/web/default/src/features/system-settings/general/channel-affinity/index.tsx @@ -95,30 +95,31 @@ interface Props { export function ChannelAffinitySection(props: Props) { const { t } = useTranslation() const updateOption = useUpdateOption() + const resetKey = JSON.stringify(props.defaultValues) const [enabled, setEnabled] = useResettableState( props.defaultValues['channel_affinity_setting.enabled'], - props.defaultValues + resetKey ) const [switchOnSuccess, setSwitchOnSuccess] = useResettableState( props.defaultValues['channel_affinity_setting.switch_on_success'], - props.defaultValues + resetKey ) const [keepOnChannelDisabled, setKeepOnChannelDisabled] = useResettableState( props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'], - props.defaultValues + resetKey ) const [maxEntries, setMaxEntries] = useResettableState( props.defaultValues['channel_affinity_setting.max_entries'], - props.defaultValues + resetKey ) const [defaultTtl, setDefaultTtl] = useResettableState( props.defaultValues['channel_affinity_setting.default_ttl_seconds'], - props.defaultValues + resetKey ) const [rules, setRules] = useResettableState( () => parseRules(props.defaultValues['channel_affinity_setting.rules']), - props.defaultValues + resetKey ) const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') @@ -131,7 +132,7 @@ export function ChannelAffinitySection(props: Props) { null, 2 ), - props.defaultValues + resetKey ) const [cacheStats, setCacheStats] = useState(null) const [cacheLoading, setCacheLoading] = useState(true) diff --git a/web/default/src/hooks/state-lifecycle.runtime.test.tsx b/web/default/src/hooks/state-lifecycle.runtime.test.tsx index 28d9eff7..adb1fab5 100644 --- a/web/default/src/hooks/state-lifecycle.runtime.test.tsx +++ b/web/default/src/hooks/state-lifecycle.runtime.test.tsx @@ -18,6 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { act, useState } from 'react' import { AxiosError } from 'axios' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' import assert from 'node:assert/strict' import { after, test } from 'node:test' @@ -25,6 +26,7 @@ import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' import { useThemeRadiusPx } from '@/lib/theme-radius' import type { UserProfile } from '@/features/profile/types' +import type { ChannelAffinitySettings } from '@/features/system-settings/general/channel-affinity/types' import { useMinimumLoadingTime } from './use-minimum-loading-time' import { useIsMobile } from './use-mobile' import { useReadScope } from './use-read-scope' @@ -36,8 +38,127 @@ const { fireEvent, within } = await import('@testing-library/react/pure') const { useProfile } = await import('@/features/profile/hooks/use-profile') const { NotificationTab } = await import('@/features/profile/components/tabs/notification-tab') +const { ChannelAffinitySection } = + await import('@/features/system-settings/general/channel-affinity') +const { SettingsPageProvider } = + await import('@/features/system-settings/components/settings-page-context') after(() => env.teardown()) +test('channel affinity drafts survive equal saved values and reset together after a saved change', async () => { + const adapter = api.defaults.adapter + const requests: string[] = [] + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const actions = env.dom.window.document.createElement('div') + env.dom.window.document.body.append(actions) + const initial: ChannelAffinitySettings = { + 'channel_affinity_setting.enabled': true, + 'channel_affinity_setting.switch_on_success': true, + 'channel_affinity_setting.keep_on_channel_disabled': false, + 'channel_affinity_setting.max_entries': 100, + 'channel_affinity_setting.default_ttl_seconds': 60, + 'channel_affinity_setting.rules': '[]', + } + api.defaults.adapter = async (config) => { + requests.push(config.method || '') + assert.equal(config.method, 'get') + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data: null }, + } + } + function Harness() { + const [saved, setSaved] = useState(initial) + const [, setRevision] = useState(0) + return ( + <> + + + + + ) + } + const view = await env.render( + + + + + + ) + const fields = within(view.container), + controls = within(actions) + const switches = () => fields.getAllByRole('switch') + const amounts = () => fields.getAllByRole('spinbutton') as HTMLInputElement[] + try { + for (const field of switches()) await view.click(field) + await act(async () => { + fireEvent.change(amounts()[0], { target: { value: '0' } }) + fireEvent.change(amounts()[1], { target: { value: '0' } }) + }) + await view.click(controls.getByRole('button', { name: 'JSON' })) + const draft = '[{"name":"unsaved-rule","ttl_seconds":0}]' + await act(async () => { + fireEvent.change(fields.getByRole('textbox'), { + target: { value: draft }, + }) + }) + await view.click(fields.getByText('Refresh affinity host')) + assert.deepEqual( + switches().map((field) => field.getAttribute('aria-checked')), + ['false', 'false', 'true'] + ) + assert.deepEqual( + amounts().map((field) => field.value), + ['0', '0'] + ) + assert.equal( + (fields.getByRole('textbox') as HTMLTextAreaElement).value, + draft + ) + await view.click(controls.getByRole('button', { name: 'Visual' })) + await view.click(fields.getByText('Refresh affinity host')) + assert.ok(fields.getByText('unsaved-rule')) + await view.click(fields.getByText('Change saved affinity')) + assert.deepEqual( + switches().map((field) => field.getAttribute('aria-checked')), + ['true', 'true', 'false'] + ) + assert.deepEqual( + amounts().map((field) => field.value), + ['222', '60'] + ) + assert.equal(fields.queryByText('unsaved-rule'), null) + assert.ok(fields.getByText('saved-rule')) + await view.click(controls.getByRole('button', { name: 'JSON' })) + assert.deepEqual( + JSON.parse((fields.getByRole('textbox') as HTMLTextAreaElement).value), + [{ name: 'saved-rule' }] + ) + assert.deepEqual(requests, ['get']) + } finally { + await view.unmount() + actions.remove() + client.clear() + api.defaults.adapter = adapter + } +}) + test('notification draft survives an unchanged profile refresh and resets for saved settings or account', async () => { const adapter = api.defaults.adapter const writes: Array<{ From 24e7c72bbac8b65bfab10e4ae890a0a059006bd1 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 16:40:47 +0800 Subject: [PATCH 10/23] fix(web): retain tag model drafts and recover sidebar settings --- .../components/dialogs/edit-tag-dialog.tsx | 20 ++- .../read-dialog-lifecycle.runtime.test.tsx | 140 +++++++++++++++++- .../components/sidebar-modules-card.tsx | 42 ++++-- .../models/upstream-ratio-sync.tsx | 4 +- .../hooks/state-lifecycle.runtime.test.tsx | 55 +++++++ 5 files changed, 239 insertions(+), 22 deletions(-) diff --git a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx index 2c467613..6c0f6eda 100644 --- a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx @@ -102,10 +102,24 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { allModelsData?.data?.map((m) => m.id).filter(Boolean) || [] const availableGroups = groupsData?.data || [] - const [selectedModels, setSelectedModels] = useResettableState( - () => tagModelsData?.data?.split(',').filter(Boolean) || [], - JSON.stringify([open, currentTag, tagModelsData?.data]) + const [modelDraft, setModelDraft] = useResettableState( + () => ({ + source: tagModelsData?.data, + edited: false, + models: tagModelsData?.data?.split(',').filter(Boolean) || [], + }), + draftKey ) + if (!modelDraft.edited && modelDraft.source !== tagModelsData?.data) { + setModelDraft({ + source: tagModelsData?.data, + edited: false, + models: tagModelsData?.data?.split(',').filter(Boolean) || [], + }) + } + const selectedModels = modelDraft.models + const setSelectedModels = (models: string[]) => + setModelDraft((previous) => ({ ...previous, edited: true, models })) const handleAddCustomModel = () => { if (!customModel.trim()) return diff --git a/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx index 3ddb7ef0..41ab9f10 100644 --- a/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx +++ b/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx @@ -35,10 +35,12 @@ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: env.dom.window.localStorage, }) -const { within, waitFor } = await import('@testing-library/react/pure') +const { fireEvent, within, waitFor } = + await import('@testing-library/react/pure') const { ChannelsProvider, useChannels } = await import('../channels-provider') const { MultiKeyManageDialog } = await import('./multi-key-manage-dialog') const { BalanceQueryDialog } = await import('./balance-query-dialog') +const { EditTagDialog } = await import('./edit-tag-dialog') const { UserBindingDialog } = await import('@/features/users/components/dialogs/user-binding-dialog') after(() => { @@ -76,6 +78,142 @@ function ChannelHarness({ mode }: { mode: 'keys' | 'usage' }) { ) } +test('tag model drafts accept fresh pristine data and protect edits until scope changes', async () => { + const adapter = api.defaults.adapter + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const pending: Array<(models: string) => void> = [] + let tagReads = 0, + writes = 0 + api.defaults.adapter = async (config) => { + if (config.method !== 'get') writes += 1 + let data: unknown = [] + if (config.url === '/api/channel/tag/models') { + if (config.params.tag === 'first') { + tagReads += 1 + data = + tagReads <= 3 + ? await new Promise((resolve) => pending.push(resolve)) + : 'server-refetched' + } else data = 'second-model' + } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data }, + } + } + function Harness() { + const { setCurrentTag } = useChannels() + const [open, setOpen] = useState(true) + const [, setRevision] = useState(0) + return ( + <> + + + + + + + ) + } + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body), + host = within(view.container) + try { + await view.click(host.getByText('Edit first tag')) + await waitFor(() => assert.equal(pending.length, 1)) + await act(async () => { + fireEvent.change( + body.getByPlaceholderText('Enter new tag name or leave empty'), + { target: { value: 'renamed-first' } } + ) + pending[0]('server-initial') + }) + await waitFor(() => assert.ok(body.queryByText(/server-initial/))) + let pristineRefetch: Promise | undefined + await act(async () => { + pristineRefetch = client.refetchQueries({ + queryKey: ['tag-models', 'first'], + exact: true, + }) + }) + await waitFor(() => assert.equal(pending.length, 2)) + await act(async () => { + pending[1]('server-pristine') + await pristineRefetch + }) + assert.equal( + client.getQueryData<{ data: string }>(['tag-models', 'first'])?.data, + 'server-pristine' + ) + await view.click(host.getByText('Render tag host again')) + assert.ok(body.queryByText(/server-pristine/)) + await act(async () => { + fireEvent.change( + body.getByPlaceholderText('Custom model (comma-separated)'), + { target: { value: 'local-model' } } + ) + }) + await view.click(body.getByRole('button', { name: 'Add' })) + assert.ok(body.queryByText(/local-model/)) + let refetch: Promise | undefined + await act(async () => { + refetch = client.refetchQueries({ + queryKey: ['tag-models', 'first'], + exact: true, + }) + }) + await waitFor(() => assert.equal(pending.length, 3)) + await act(async () => { + pending[2]('server-refetched') + await refetch + }) + assert.equal( + client.getQueryData<{ data: string }>(['tag-models', 'first'])?.data, + 'server-refetched' + ) + await view.click(host.getByText('Render tag host again')) + assert.ok(body.queryByText(/local-model/)) + assert.ok(body.queryByText(/server-pristine/)) + assert.equal( + ( + body.getByPlaceholderText( + 'Enter new tag name or leave empty' + ) as HTMLInputElement + ).value, + 'renamed-first' + ) + await view.click(host.getByText('Toggle tag editor')) + await view.click(host.getByText('Toggle tag editor')) + await waitFor(() => assert.ok(body.queryByText(/server-refetched/))) + assert.equal(body.queryByText(/local-model/), null) + await view.click(host.getByText('Edit second tag')) + await waitFor(() => assert.ok(body.queryByText(/second-model/))) + assert.equal(body.queryByText(/server-refetched/), null) + assert.equal(writes, 0) + assert.ok(tagReads >= 3) + } finally { + pending.forEach((release) => release('server-refetched')) + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + test('late multi-key results cannot replace the current channel keys', async () => { const adapter = api.defaults.adapter const pending = new Map void>() diff --git a/web/default/src/features/profile/components/sidebar-modules-card.tsx b/web/default/src/features/profile/components/sidebar-modules-card.tsx index 8c9e1e40..9caa1ec9 100644 --- a/web/default/src/features/profile/components/sidebar-modules-card.tsx +++ b/web/default/src/features/profile/components/sidebar-modules-card.tsx @@ -48,6 +48,18 @@ type SectionDef = { modules: { key: string; title: string; description: string }[] } +function buildDefaultSidebarConfig( + sections: SectionDef[] +): SidebarModulesConfig { + const defaults: SidebarModulesConfig = {} + for (const section of sections) { + defaults[section.key] = { enabled: true } + for (const module of section.modules) + defaults[section.key][module.key] = true + } + return defaults +} + export function SidebarModulesCard() { const { t } = useTranslation() const [loading, setLoading] = useState(false) @@ -135,18 +147,21 @@ export function SidebarModulesCard() { .get('/api/user/self') .then((res) => { if (!current()) return - if (res.data.success && res.data.data?.sidebar_modules) { - const raw = res.data.data.sidebar_modules - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw - setConfig(parsed) - } else { - const defaults: SidebarModulesConfig = {} - for (const sec of sectionDefs) { - defaults[sec.key] = { enabled: true } - for (const mod of sec.modules) defaults[sec.key][mod.key] = true + let parsed: unknown = res.data.success + ? res.data.data?.sidebar_modules + : undefined + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed) + } catch { + parsed = null } - setConfig(defaults) } + setConfig( + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as SidebarModulesConfig) + : buildDefaultSidebarConfig(sectionDefs) + ) }) .catch(() => { /* retain the last valid configuration */ @@ -201,12 +216,7 @@ export function SidebarModulesCard() { } const handleReset = () => { - const defaults: SidebarModulesConfig = {} - for (const sec of sectionDefs) { - defaults[sec.key] = { enabled: true } - for (const mod of sec.modules) defaults[sec.key][mod.key] = true - } - setConfig(defaults) + setConfig(buildDefaultSidebarConfig(sectionDefs)) toast.success(t('Reset to default configuration')) } diff --git a/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx b/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx index 53637015..2532b6da 100644 --- a/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx +++ b/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx @@ -145,7 +145,7 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { const [channelDialogOpen, setChannelDialogOpen] = useState(false) const [conflictDialogOpen, setConflictDialogOpen] = useState(false) const [selectedChannelIds, setSelectedChannelIds] = useState([]) - const [endpointOverrides, setChannelEndpoints] = useState< + const [endpointOverrides, setEndpointOverrides] = useState< Record >({}) const [differences, setDifferences] = useState({}) @@ -544,7 +544,7 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { selectedChannelIds={selectedChannelIds} onSelectedChannelIdsChange={setSelectedChannelIds} channelEndpoints={channelEndpoints} - onChannelEndpointsChange={setChannelEndpoints} + onChannelEndpointsChange={setEndpointOverrides} onConfirm={handleConfirmChannelSelection} /> diff --git a/web/default/src/hooks/state-lifecycle.runtime.test.tsx b/web/default/src/hooks/state-lifecycle.runtime.test.tsx index adb1fab5..9dd346f4 100644 --- a/web/default/src/hooks/state-lifecycle.runtime.test.tsx +++ b/web/default/src/hooks/state-lifecycle.runtime.test.tsx @@ -42,8 +42,63 @@ const { ChannelAffinitySection } = await import('@/features/system-settings/general/channel-affinity') const { SettingsPageProvider } = await import('@/features/system-settings/components/settings-page-context') +const { SidebarModulesCard } = + await import('@/features/profile/components/sidebar-modules-card') after(() => env.teardown()) +test('sidebar settings recover malformed values and preserve explicit false without autosaving', async () => { + const adapter = api.defaults.adapter, + previousUser = useAuthStore.getState().auth.user + const defaults = { + chat: { enabled: true, playground: true, chat: true }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true, + }, + personal: { enabled: true, topup: true, personal: true }, + } + const valid = { chat: { enabled: false, chat: false } } + const cases = ['{broken', 'null', '[]', 'false', '42', JSON.stringify(valid)] + try { + for (const [index, raw] of cases.entries()) { + useAuthStore + .getState() + .auth.setUser({ id: 30 + index, username: 'sidebar-user', role: 1 }) + const saved: unknown[] = [] + api.defaults.adapter = async (config) => { + assert.equal(config.url, '/api/user/self') + if (config.method === 'put') + saved.push(JSON.parse(JSON.parse(config.data).sidebar_modules)) + else assert.equal(config.method, 'get') + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data: { sidebar_modules: raw } }, + } + } + const view = await env.render() + try { + assert.equal(saved.length, 0) + await view.click( + within(view.container).getByRole('button', { name: 'Save Changes' }) + ) + assert.deepEqual(saved, [index === cases.length - 1 ? valid : defaults]) + } finally { + await view.unmount() + } + } + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(previousUser) + } +}) + test('channel affinity drafts survive equal saved values and reset together after a saved change', async () => { const adapter = api.defaults.adapter const requests: string[] = [] From 306b3df9d25c136072247d42c41fe3cf7bb1ca2c Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 19:27:09 +0800 Subject: [PATCH 11/23] fix(web): isolate wallet session reads and amount drafts --- .../wallet/components/recharge-form-card.tsx | 12 +- .../components/subscription-plans-card.tsx | 102 +++++++++----- .../wallet/wallet-lifecycle.runtime.test.tsx | 133 +++++++++++++++++- 3 files changed, 202 insertions(+), 45 deletions(-) diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index ddb239bc..7bfac7e6 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -110,14 +110,18 @@ export function RechargeFormCard({ enableWaffoPancakeTopup, }: RechargeFormCardProps) { const { t } = useTranslation() - const [localAmount, setLocalAmount] = useResettableState( - topupAmount.toString(), - topupAmount + const [amountDraft, setAmountDraft] = useResettableState( + () => ({ source: topupAmount, value: topupAmount.toString() }), + 'recharge-amount' ) + if (amountDraft.source !== topupAmount) { + setAmountDraft({ source: topupAmount, value: topupAmount.toString() }) + } + const localAmount = amountDraft.value const handleAmountChange = (value: string) => { - setLocalAmount(value) const numValue = parseInt(value) || 0 + setAmountDraft({ source: numValue, value }) if (numValue >= 0) { onTopupAmountChange(numValue) } diff --git a/web/default/src/features/wallet/components/subscription-plans-card.tsx b/web/default/src/features/wallet/components/subscription-plans-card.tsx index 9ed94120..83f712d0 100644 --- a/web/default/src/features/wallet/components/subscription-plans-card.tsx +++ b/web/default/src/features/wallet/components/subscription-plans-card.tsx @@ -20,8 +20,11 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { Crown, RefreshCw, Sparkles, Check } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' import { formatQuota } from '@/lib/format' import { cn } from '@/lib/utils' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' @@ -97,19 +100,30 @@ export function SubscriptionPlansCard({ onPurchaseSuccess, }: SubscriptionPlansCardProps) { const { t } = useTranslation() + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginPlansRead = useReadScope(sessionRevision) + const beginSubscriptionRead = useReadScope(sessionRevision) - const [plans, setPlans] = useState([]) - const [activeSubscriptions, setActiveSubscriptions] = useState< + const [plans, setPlans] = useResettableState( + [], + sessionRevision + ) + const [activeSubscriptions, setActiveSubscriptions] = useResettableState< UserSubscriptionRecord[] - >([]) - const [allSubscriptions, setAllSubscriptions] = useState< + >([], sessionRevision) + const [allSubscriptions, setAllSubscriptions] = useResettableState< UserSubscriptionRecord[] - >([]) - const [billingPreference, setBillingPreference] = - useState('subscription_first') - const [loading, setLoading] = useState(true) + >([], sessionRevision) + const [billingPreference, setBillingPreference] = useResettableState( + 'subscription_first', + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) const [refreshing, setRefreshing] = useState(false) - const [now, setNow] = useState(() => Date.now() / 1000) + const [now, setNow] = useResettableState( + () => Date.now() / 1000, + sessionRevision + ) const [purchaseOpen, setPurchaseOpen] = useState(false) const [selectedPlan, setSelectedPlan] = useState(null) @@ -123,34 +137,44 @@ export function SubscriptionPlansCard({ [topupInfo?.pay_methods] ) - const fetchPlans = useCallback( - () => - getPublicPlans() - .then((res) => { - if (res.success) setPlans(res.data || []) - }) - .catch(() => setPlans([])), - [] - ) - - const fetchSelfSubscription = useCallback( - () => - getSelfSubscriptionFull() - .then((res) => { - if (res.success && res.data) { - setBillingPreference( - res.data.billing_preference || 'subscription_first' - ) - setActiveSubscriptions(res.data.subscriptions || []) - setAllSubscriptions(res.data.all_subscriptions || []) - setNow(Date.now() / 1000) - } - }) - .catch(() => { - /* retain the last successful read */ - }), - [] - ) + const fetchPlans = useCallback(() => { + const current = beginPlansRead() + if (!current()) return Promise.resolve() + return getPublicPlans() + .then((res) => { + if (!current()) return + if (res.success) setPlans(res.data || []) + }) + .catch(() => { + if (current()) setPlans([]) + }) + }, [beginPlansRead, setPlans]) + + const fetchSelfSubscription = useCallback(() => { + const current = beginSubscriptionRead() + if (!current()) return Promise.resolve() + return getSelfSubscriptionFull() + .then((res) => { + if (!current()) return + if (res.success && res.data) { + setBillingPreference( + res.data.billing_preference || 'subscription_first' + ) + setActiveSubscriptions(res.data.subscriptions || []) + setAllSubscriptions(res.data.all_subscriptions || []) + setNow(Date.now() / 1000) + } + }) + .catch(() => { + /* retain the last successful read */ + }) + }, [ + beginSubscriptionRead, + setActiveSubscriptions, + setAllSubscriptions, + setBillingPreference, + setNow, + ]) useEffect(() => { let current = true @@ -160,7 +184,7 @@ export function SubscriptionPlansCard({ return () => { current = false } - }, [fetchPlans, fetchSelfSubscription]) + }, [fetchPlans, fetchSelfSubscription, setLoading]) useEffect(() => { const current = Date.now() const day = 86_400_000 @@ -179,7 +203,7 @@ export function SubscriptionPlansCard({ if (!Number.isFinite(next)) return const timer = setTimeout(() => setNow(Date.now() / 1000), next) return () => clearTimeout(timer) - }, [allSubscriptions, now]) + }, [allSubscriptions, now, setNow]) const handleRefresh = async () => { setRefreshing(true) diff --git a/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx b/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx index 9cb3aaf3..33c581cd 100644 --- a/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx +++ b/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx @@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createReactTestEnvironment } from '@/test/react' import assert from 'node:assert/strict' import { after, test } from 'node:test' +import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' import { subscriptionPlanSchema, @@ -137,6 +138,48 @@ test('wallet custom zero stays explicit instead of being initialized again', asy { target: { value: '0' } } ) }) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '05' } } + ) + }) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '05' + ) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '1.5' } } + ) + }) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '1.5' + ) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '' } } + ) + }) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '' + ) await view.click(fields.getByText('Render wallet again')) assert.equal( ( @@ -144,9 +187,9 @@ test('wallet custom zero stays explicit instead of being initialized again', asy name: 'Custom Amount', }) as HTMLInputElement ).value, - '0' + '' ) - assert.deepEqual(quotes, [10, 0]) + assert.deepEqual(quotes, [10, 0, 5, 1, 0]) assert.deepEqual(mutations, []) } finally { await view.unmount() @@ -155,6 +198,92 @@ test('wallet custom zero stays explicit instead of being initialized again', asy } }) +test('subscription reads ignore stale responses after the auth session changes', async () => { + const adapter = api.defaults.adapter + const previousUser = useAuthStore.getState().auth.user + const pending: Array<() => void> = [] + const subscription = (id: number) => ({ + id, + user_id: id, + plan_id: 1, + status: 'active', + start_time: 1710000000, + end_time: 1910000000, + amount_total: 100, + amount_used: 0, + }) + try { + useAuthStore + .getState() + .auth.setUser({ id: 1, username: 'first-user', role: 1 }) + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + if (config.url === '/api/subscription/plans') { + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data: [] }, + } + } + if (config.url === '/api/subscription/self') { + const index = pending.length + return new Promise((resolve) => { + pending.push(() => + resolve({ + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { + billing_preference: + index === 0 ? 'wallet_first' : 'wallet_only', + subscriptions: [ + { subscription: subscription(index === 0 ? 1 : 2) }, + ], + all_subscriptions: [ + { subscription: subscription(index === 0 ? 1 : 2) }, + ], + }, + }, + }) + ) + }) + } + throw new Error(`Unexpected ${config.url}`) + } + const view = await env.render( + + ) + try { + await waitFor(() => assert.equal(pending.length, 1)) + await act(async () => { + useAuthStore + .getState() + .auth.setUser({ id: 2, username: 'second-user', role: 1 }) + }) + await waitFor(() => assert.equal(pending.length, 2)) + await act(async () => pending[1]()) + await waitFor(() => + assert.match(view.container.textContent || '', /Subscription #2/) + ) + await act(async () => pending[0]()) + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.match(view.container.textContent || '', /Subscription #2/) + assert.doesNotMatch(view.container.textContent || '', /Subscription #1/) + } finally { + pending.forEach((release) => release()) + await view.unmount() + } + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(previousUser) + } +}) + test('epay selection survives a host rerender and an in-flight payment stays locked across reopen', async () => { const adapter = api.defaults.adapter const plan: PlanRecord = { From 7168f567db8dc8b05003ff1db7cbe004baeb64d4 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 02:36:07 +0800 Subject: [PATCH 12/23] feat(ops): add safe sqlite archive rehearsal tooling --- docs/installation/sqlite-operations.md | 56 +++ docs/installation/sqlite-upgrade.md | 4 + tools/sqliteops/main.go | 522 +++++++++++++++++++++++++ tools/sqliteops/main_test.go | 113 ++++++ 4 files changed, 695 insertions(+) create mode 100644 docs/installation/sqlite-operations.md create mode 100644 tools/sqliteops/main.go create mode 100644 tools/sqliteops/main_test.go diff --git a/docs/installation/sqlite-operations.md b/docs/installation/sqlite-operations.md new file mode 100644 index 00000000..cbfed7ea --- /dev/null +++ b/docs/installation/sqlite-operations.md @@ -0,0 +1,56 @@ +# SQLite 运维备份与恢复 / SQLite Operations Backup and Restore + +`tools/sqliteops` 只处理**已停写**的 SQLite 文件副本。它不会打开数据库、执行迁移、复制配置内容或修改业务数据。 + +`tools/sqliteops` operates only on a **quiesced** SQLite file set. It does not open a database, run migrations, copy configuration contents, or modify business data. + +## 成套备份 / Complete archive + +先停止 API 进程、后台写入任务和结算恢复任务,确认没有新的请求或日志写入,然后执行: + +```powershell +go run ./tools/sqliteops backup ` + --quiesced ` + --primary C:\data\ledger.sqlite ` + --log C:\data\logs.sqlite ` + --config C:\data\runtime.env ` + --destination C:\archives\max-api-20261016 +``` + +`--log` 省略或与 `--primary` 相同时,主库同时作为日志库归档。SQLite 的 `-wal` 和 `-shm` 文件存在时会随对应数据库一起归档。配置文件只记录文件名、大小和 SHA-256,**不会复制配置内容或密钥**。 + +The `--log` argument may be omitted, or may point to the primary file when the application uses a shared database. Existing SQLite `-wal` and `-shm` sidecars are archived with their database. Configuration is fingerprinted by name, size, and SHA-256 only; its contents and secrets are never copied. + +## 验证与隔离恢复 / Verify and isolated restore + +归档不得覆盖已有目录。验证归档清单和所有文件哈希: + +```powershell +go run ./tools/sqliteops verify --backup C:\archives\max-api-20261016 +``` + +恢复到全新的空目录: + +```powershell +go run ./tools/sqliteops restore ` + --backup C:\archives\max-api-20261016 ` + --target C:\rehearsals\max-api-20261016 +``` + +验证会拒绝缺失、篡改、路径逃逸和未列入清单的额外文件。恢复只复制已验证的数据库文件和 WAL sidecar;配置仍需人工按安全渠道准备。恢复副本必须先使用当前版本执行初始化、重启和业务快照核对,不能直接接入生产流量。 + +Verification rejects missing, modified, path-escaping, and unlisted files. Restore copies only verified database files and WAL sidecars; configuration must be supplied separately through a secure channel. Run initialization, restart, and business-snapshot checks against the restored copy before any traffic is admitted. + +## 边界 / Boundaries + +- `--quiesced` 是强制门禁;运行中复制单个 SQLite 文件不能证明恢复点一致。 +- 归档不代表主库、日志库和外部支付状态已经形成跨系统原子快照。 +- manual、unknown outcome、跨订阅周期和外部已接受但本地未完成的结算必须单独对账。 +- 新版本写入不保证旧版本可以直接降级;回退使用完整升级前归档,并核对期间新增写入。 +- 本工具不验证 MySQL/PostgreSQL、Redis、多主机、真实渠道、OAuth、掉电恢复或浏览器流程。 + +- `--quiesced` is mandatory; copying one SQLite file while it is live does not establish a consistent restore point. +- An archive is not an atomic snapshot across the business database, log database, and external payment state. +- Manual, unknown-outcome, cross-period, and externally accepted but locally incomplete settlements require separate reconciliation. +- New-version writes are not guaranteed to support direct downgrade; rollback uses the complete pre-upgrade archive and reconciles writes made during the rehearsal. +- This tool does not validate MySQL/PostgreSQL, Redis, multi-host operation, real providers, OAuth, power loss, or browser flows. diff --git a/docs/installation/sqlite-upgrade.md b/docs/installation/sqlite-upgrade.md index 41e8146e..4026de79 100644 --- a/docs/installation/sqlite-upgrade.md +++ b/docs/installation/sqlite-upgrade.md @@ -1,5 +1,9 @@ # SQLite 升级验收 / SQLite upgrade acceptance +备份、清单校验和隔离恢复命令见[SQLite 运维备份与恢复](sqlite-operations.md)。 + +See [SQLite operations backup and restore](sqlite-operations.md) for the archive, manifest verification, and isolated restore commands. + 升级前停止接收新请求,在原数据库完成待处理资金结算及日志副作用,停止服务后一起备份业务库、日志库和配置。SQLite 使用 WAL 时,保留尚未合并的 WAL 文件;运行中的单个数据库文件复制不能作为完整备份。 Before upgrading, stop new traffic, finish pending funding settlements and log effects against their original databases, stop the service, and back up the business database, log database, and configuration together. Preserve uncheckpointed WAL files. Copying only a database file while it is in use does not establish a complete backup. diff --git a/tools/sqliteops/main.go b/tools/sqliteops/main.go new file mode 100644 index 00000000..2e80d623 --- /dev/null +++ b/tools/sqliteops/main.go @@ -0,0 +1,522 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +// sqliteops provides a conservative, offline archive for a quiesced SQLite +// business database and its optional independent log database. It never opens +// or mutates either database and never copies configuration contents. +package main + +import ( + "crypto/sha256" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/MAX-API-Next/MAX-API/common" +) + +const ( + manifestName = "manifest.json" + manifestVersion = 1 +) + +type manifest struct { + SchemaVersion int `json:"schema_version"` + CreatedAt string `json:"created_at"` + Tool string `json:"tool"` + Files []manifestFile `json:"files"` + Configuration *configurationHash `json:"configuration,omitempty"` +} + +type manifestFile struct { + Roles []string `json:"roles"` + ArchivePath string `json:"archive_path"` + SourceName string `json:"source_name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Required bool `json:"required"` +} + +type configurationHash struct { + SourceName string `json:"source_name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Copied bool `json:"copied"` +} + +type sourceFile struct { + roles []string + path string + archivePath string +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + var err error + switch os.Args[1] { + case "backup": + err = runBackup(os.Args[2:]) + case "verify": + err = runVerify(os.Args[2:]) + case "restore": + err = runRestore(os.Args[2:]) + case "help", "-h", "--help": + usage() + return + default: + usage() + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + if err != nil { + fmt.Fprintln(os.Stderr, "sqliteops:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: sqliteops backup --quiesced --primary PATH --destination DIR [--log PATH] [--config PATH]") + fmt.Fprintln(os.Stderr, " sqliteops verify --backup DIR") + fmt.Fprintln(os.Stderr, " sqliteops restore --backup DIR --target DIR") +} + +func runBackup(args []string) error { + flags := flag.NewFlagSet("backup", flag.ContinueOnError) + flags.SetOutput(io.Discard) + primary := flags.String("primary", "", "business SQLite file") + logPath := flags.String("log", "", "independent log SQLite file") + config := flags.String("config", "", "configuration file to fingerprint, never copied") + destination := flags.String("destination", "", "new archive directory") + quiesced := flags.Bool("quiesced", false, "assert that all application and background writers are stopped") + if err := flags.Parse(args); err != nil { + return err + } + if !*quiesced { + return errors.New("refusing a backup without --quiesced; stop application and background writers first") + } + if strings.TrimSpace(*primary) == "" || strings.TrimSpace(*destination) == "" { + return errors.New("--primary and --destination are required") + } + + root, err := prepareDestination(*destination, *primary, *logPath, *config) + if err != nil { + return err + } + sources, err := collectSources(*primary, *logPath) + if err != nil { + return err + } + m := manifest{ + SchemaVersion: manifestVersion, + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + Tool: "MAX-API sqliteops", + } + for _, source := range sources { + entry, err := archiveSource(root, source) + if err != nil { + return err + } + m.Files = append(m.Files, entry) + } + if *config != "" { + fingerprint, err := fingerprintFile(*config) + if err != nil { + return fmt.Errorf("fingerprint config: %w", err) + } + fingerprint.Copied = false + m.Configuration = &fingerprint + } + sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].ArchivePath < m.Files[j].ArchivePath }) + if err := writeManifest(root, m); err != nil { + return err + } + return verifyArchive(root) +} + +func runVerify(args []string) error { + flags := flag.NewFlagSet("verify", flag.ContinueOnError) + flags.SetOutput(io.Discard) + backup := flags.String("backup", "", "archive directory") + if err := flags.Parse(args); err != nil { + return err + } + if strings.TrimSpace(*backup) == "" { + return errors.New("--backup is required") + } + root, err := filepath.Abs(*backup) + if err != nil { + return err + } + return verifyArchive(root) +} + +func runRestore(args []string) error { + flags := flag.NewFlagSet("restore", flag.ContinueOnError) + flags.SetOutput(io.Discard) + backup := flags.String("backup", "", "archive directory") + target := flags.String("target", "", "new empty restore directory") + if err := flags.Parse(args); err != nil { + return err + } + if strings.TrimSpace(*backup) == "" || strings.TrimSpace(*target) == "" { + return errors.New("--backup and --target are required") + } + backupRoot, err := filepath.Abs(*backup) + if err != nil { + return err + } + if err := verifyArchive(backupRoot); err != nil { + return err + } + targetRoot, err := filepath.Abs(*target) + if err != nil { + return err + } + if err := ensureEmptyDestination(targetRoot); err != nil { + return err + } + if pathsOverlap(backupRoot, targetRoot) { + return errors.New("restore target must not overlap the backup archive") + } + if err := os.MkdirAll(targetRoot, 0o700); err != nil { + return err + } + m, err := readManifest(backupRoot) + if err != nil { + return err + } + for _, item := range m.Files { + source, err := safeArchivePath(backupRoot, item.ArchivePath) + if err != nil { + return err + } + destination, err := safeArchivePath(targetRoot, item.ArchivePath) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + if err := copyRegularFile(source, destination, 0o600); err != nil { + return err + } + } + return copyRegularFile(filepath.Join(backupRoot, manifestName), filepath.Join(targetRoot, manifestName), 0o600) +} + +func collectSources(primary, logPath string) ([]sourceFile, error) { + primaryAbs, err := filepath.Abs(primary) + if err != nil { + return nil, err + } + if err := requireRegularFile(primaryAbs); err != nil { + return nil, fmt.Errorf("primary: %w", err) + } + sources := []sourceFile{{roles: []string{"primary"}, path: primaryAbs, archivePath: filepath.Join("primary", filepath.Base(primaryAbs))}} + if strings.TrimSpace(logPath) == "" || samePath(primaryAbs, logPath) { + sources[0].roles = append(sources[0].roles, "log") + return expandSidecars(sources) + } + logAbs, err := filepath.Abs(logPath) + if err != nil { + return nil, err + } + if err := requireRegularFile(logAbs); err != nil { + return nil, fmt.Errorf("log: %w", err) + } + sources = append(sources, sourceFile{roles: []string{"log"}, path: logAbs, archivePath: filepath.Join("log", filepath.Base(logAbs))}) + return expandSidecars(sources) +} + +func expandSidecars(sources []sourceFile) ([]sourceFile, error) { + var expanded []sourceFile + for _, source := range sources { + expanded = append(expanded, source) + for _, suffix := range []string{"-wal", "-shm"} { + path := source.path + suffix + if _, err := os.Stat(path); err == nil { + if err := requireRegularFile(path); err != nil { + return nil, err + } + expanded = append(expanded, sourceFile{roles: source.roles, path: path, archivePath: source.archivePath + suffix}) + } else if !os.IsNotExist(err) { + return nil, err + } + } + } + return expanded, nil +} + +func archiveSource(root string, source sourceFile) (manifestFile, error) { + destination, err := safeArchivePath(root, source.archivePath) + if err != nil { + return manifestFile{}, err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return manifestFile{}, err + } + if err := copyRegularFile(source.path, destination, 0o600); err != nil { + return manifestFile{}, err + } + fingerprint, err := fingerprintFile(destination) + if err != nil { + return manifestFile{}, err + } + return manifestFile{ + Roles: source.roles, + ArchivePath: filepath.ToSlash(source.archivePath), + SourceName: filepath.Base(source.path), + Size: fingerprint.Size, + SHA256: fingerprint.SHA256, + Required: true, + }, nil +} + +func prepareDestination(destination, primary, logPath, config string) (string, error) { + root, err := filepath.Abs(destination) + if err != nil { + return "", err + } + if err := ensureEmptyDestination(root); err != nil { + return "", err + } + for _, source := range []string{primary, logPath, config} { + if source == "" { + continue + } + sourceAbs, err := filepath.Abs(source) + if err != nil { + return "", err + } + if pathsOverlap(root, filepath.Dir(sourceAbs)) { + return "", errors.New("destination must not be inside a source directory or overlap a source") + } + } + return root, nil +} + +func verifyArchive(root string) error { + m, err := readManifest(root) + if err != nil { + return err + } + seen := map[string]struct{}{} + for _, item := range m.Files { + if item.ArchivePath == "" || item.SHA256 == "" || item.Size < 0 || !item.Required { + return fmt.Errorf("invalid manifest entry %q", item.ArchivePath) + } + if _, exists := seen[item.ArchivePath]; exists { + return fmt.Errorf("duplicate manifest entry %q", item.ArchivePath) + } + seen[item.ArchivePath] = struct{}{} + path, err := safeArchivePath(root, item.ArchivePath) + if err != nil { + return err + } + actual, err := fingerprintFile(path) + if err != nil { + return fmt.Errorf("verify %s: %w", item.ArchivePath, err) + } + if actual.Size != item.Size || actual.SHA256 != item.SHA256 { + return fmt.Errorf("archive file changed: %s", item.ArchivePath) + } + } + return rejectUnlistedFiles(root, seen) +} + +func rejectUnlistedFiles(root string, listed map[string]struct{}) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if filepath.ToSlash(rel) == manifestName { + return nil + } + if _, ok := listed[filepath.ToSlash(rel)]; !ok { + return fmt.Errorf("archive contains unlisted file %s", filepath.ToSlash(rel)) + } + return nil + }) +} + +func readManifest(root string) (manifest, error) { + data, err := os.ReadFile(filepath.Join(root, manifestName)) + if err != nil { + return manifest{}, fmt.Errorf("read manifest: %w", err) + } + var result manifest + if err := common.Unmarshal(data, &result); err != nil { + return manifest{}, fmt.Errorf("decode manifest: %w", err) + } + if result.SchemaVersion != manifestVersion || len(result.Files) == 0 { + return manifest{}, errors.New("unsupported or empty archive manifest") + } + return result, nil +} + +func writeManifest(root string, value manifest) error { + data, err := common.Marshal(value) + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(filepath.Join(root, manifestName), data, 0o600) +} + +func fingerprintFile(path string) (configurationHash, error) { + info, err := os.Stat(path) + if err != nil { + return configurationHash{}, err + } + if !info.Mode().IsRegular() { + return configurationHash{}, fmt.Errorf("not a regular file: %s", path) + } + file, err := os.Open(path) + if err != nil { + return configurationHash{}, err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return configurationHash{}, err + } + return configurationHash{SourceName: filepath.Base(path), Size: info.Size(), SHA256: fmt.Sprintf("%x", hash.Sum(nil))}, nil +} + +func copyRegularFile(source, destination string, mode os.FileMode) error { + if err := requireRegularFile(source); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + remove := true + defer func() { + _ = output.Close() + if remove { + _ = os.Remove(destination) + } + }() + if _, err := io.Copy(output, input); err != nil { + return err + } + if err := output.Sync(); err != nil { + return err + } + if err := output.Close(); err != nil { + return err + } + remove = false + return nil +} + +func ensureEmptyDestination(path string) error { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("destination is not a directory: %s", path) + } + entries, err := os.ReadDir(path) + if err != nil { + return err + } + if len(entries) > 0 { + return fmt.Errorf("destination must be new or empty: %s", path) + } + return nil +} + +func requireRegularFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("file must be regular and not a symlink: %s", path) + } + return nil +} + +func safeArchivePath(root, relative string) (string, error) { + if filepath.IsAbs(relative) || filepath.Clean(relative) == "." || strings.HasPrefix(filepath.ToSlash(filepath.Clean(relative)), "../") { + return "", fmt.Errorf("unsafe archive path: %s", relative) + } + path := filepath.Join(root, filepath.FromSlash(relative)) + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", err + } + pathAbs, err := filepath.Abs(path) + if err != nil { + return "", err + } + if !isWithin(rootAbs, pathAbs) { + return "", fmt.Errorf("archive path escapes root: %s", relative) + } + return pathAbs, nil +} + +func pathsOverlap(left, right string) bool { + leftAbs, _ := filepath.Abs(left) + rightAbs, _ := filepath.Abs(right) + return isWithin(leftAbs, rightAbs) || isWithin(rightAbs, leftAbs) +} + +func isWithin(root, path string) bool { + root = filepath.Clean(root) + path = filepath.Clean(path) + if samePath(root, path) { + return true + } + separator := string(filepath.Separator) + return strings.HasPrefix(path, root+separator) +} + +func samePath(left, right string) bool { + leftAbs, _ := filepath.Abs(left) + rightAbs, _ := filepath.Abs(right) + return strings.EqualFold(filepath.Clean(leftAbs), filepath.Clean(rightAbs)) +} diff --git a/tools/sqliteops/main_test.go b/tools/sqliteops/main_test.go new file mode 100644 index 00000000..6c6a9480 --- /dev/null +++ b/tools/sqliteops/main_test.go @@ -0,0 +1,113 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBackupVerifyAndRestoreWithWALAndConfigFingerprint(t *testing.T) { + source := t.TempDir() + primary := filepath.Join(source, "ledger.sqlite") + logPath := filepath.Join(source, "logs.sqlite") + config := filepath.Join(source, "runtime.env") + require.NoError(t, os.WriteFile(primary, []byte("ledger-v1"), 0o600)) + require.NoError(t, os.WriteFile(primary+"-wal", []byte("ledger-wal"), 0o600)) + require.NoError(t, os.WriteFile(logPath, []byte("logs-v1"), 0o600)) + require.NoError(t, os.WriteFile(logPath+"-shm", []byte("logs-shm"), 0o600)) + require.NoError(t, os.WriteFile(config, []byte("SECRET=must-not-be-copied\n"), 0o600)) + + archive := filepath.Join(t.TempDir(), "archive") + root, err := prepareDestination(archive, primary, logPath, config) + require.NoError(t, err) + sources, err := collectSources(primary, logPath) + require.NoError(t, err) + value := manifest{ + SchemaVersion: manifestVersion, + CreatedAt: "synthetic", + Tool: "test", + } + for _, sourceFile := range sources { + entry, entryErr := archiveSource(root, sourceFile) + require.NoError(t, entryErr) + value.Files = append(value.Files, entry) + } + fingerprint, err := fingerprintFile(config) + require.NoError(t, err) + fingerprint.Copied = false + value.Configuration = &fingerprint + require.NoError(t, writeManifest(root, value)) + require.NoError(t, verifyArchive(root)) + + restore := filepath.Join(t.TempDir(), "restore") + require.NoError(t, os.MkdirAll(restore, 0o700)) + require.NoError(t, runRestore([]string{"--backup", root, "--target", restore})) + require.Equal(t, []byte("ledger-v1"), mustRead(t, filepath.Join(restore, "primary", "ledger.sqlite"))) + require.Equal(t, []byte("ledger-wal"), mustRead(t, filepath.Join(restore, "primary", "ledger.sqlite-wal"))) + require.Equal(t, []byte("logs-v1"), mustRead(t, filepath.Join(restore, "log", "logs.sqlite"))) + require.NoFileExists(t, filepath.Join(restore, "runtime.env")) + manifestBytes := mustRead(t, filepath.Join(restore, manifestName)) + require.NotContains(t, string(manifestBytes), "SECRET=must-not-be-copied") +} + +func TestBackupRequiresQuiesced(t *testing.T) { + primary := filepath.Join(t.TempDir(), "ledger.sqlite") + require.NoError(t, os.WriteFile(primary, []byte("ledger"), 0o600)) + err := runBackup([]string{"--primary", primary, "--destination", filepath.Join(t.TempDir(), "archive")}) + require.ErrorContains(t, err, "--quiesced") +} + +func TestVerifyRejectsTamperingAndUnlistedFiles(t *testing.T) { + source := t.TempDir() + primary := filepath.Join(source, "ledger.sqlite") + require.NoError(t, os.WriteFile(primary, []byte("ledger"), 0o600)) + archive := filepath.Join(t.TempDir(), "archive") + require.NoError(t, runBackup([]string{"--quiesced", "--primary", primary, "--destination", archive})) + require.NoError(t, os.WriteFile(filepath.Join(archive, "primary", "ledger.sqlite"), []byte("tampered"), 0o600)) + require.ErrorContains(t, verifyArchive(archive), "archive file changed") + + archive2 := filepath.Join(t.TempDir(), "archive") + require.NoError(t, runBackup([]string{"--quiesced", "--primary", primary, "--destination", archive2})) + require.NoError(t, os.WriteFile(filepath.Join(archive2, "extra.txt"), []byte("mixed"), 0o600)) + require.ErrorContains(t, verifyArchive(archive2), "unlisted file") +} + +func TestBackupRejectsDestinationInsideSource(t *testing.T) { + source := t.TempDir() + primary := filepath.Join(source, "ledger.sqlite") + require.NoError(t, os.WriteFile(primary, []byte("ledger"), 0o600)) + err := runBackup([]string{ + "--quiesced", + "--primary", primary, + "--destination", filepath.Join(source, "archive"), + }) + require.ErrorContains(t, err, "source directory") +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return data +} From ea8526bcb778737895f1690f06fa98f6a9719c8e Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 03:02:48 +0800 Subject: [PATCH 13/23] test(sqlite): rehearse restored business snapshots --- docs/installation/sqlite-operations.md | 12 + model/sqlite_operations_acceptance_test.go | 321 +++++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 model/sqlite_operations_acceptance_test.go diff --git a/docs/installation/sqlite-operations.md b/docs/installation/sqlite-operations.md index cbfed7ea..c14664ac 100644 --- a/docs/installation/sqlite-operations.md +++ b/docs/installation/sqlite-operations.md @@ -41,6 +41,16 @@ go run ./tools/sqliteops restore ` Verification rejects missing, modified, path-escaping, and unlisted files. Restore copies only verified database files and WAL sidecars; configuration must be supplied separately through a secure channel. Run initialization, restart, and business-snapshot checks against the restored copy before any traffic is admitted. +## 业务快照验收 / Business snapshot rehearsal + +仓库中的合成验收会使用当前模型创建主库和独立日志库,执行归档与两次隔离恢复,然后重新打开恢复文件,比较用户额度、Token、渠道用量、订阅周期与预消费、settlement、日志和 receipt。它只验证文件归档及当前 SQLite 模型数据的可读性,不代表真实生产库、跨库原子性或外部支付状态已经验证: + +```powershell +go test ./model -run TestSQLiteOperationsAcceptancePreservesBusinessSnapshot -count=1 +``` + +The repository rehearsal creates a synthetic primary database and independent log database from the current models, archives them, restores them twice into isolated directories, reopens both copies, and compares wallet quota, token and channel usage, subscription/pre-consume state, settlement, logs, and receipts. It validates the archive and current SQLite model readability only; it does not establish production-database, cross-database atomicity, or external-payment correctness. + ## 边界 / Boundaries - `--quiesced` 是强制门禁;运行中复制单个 SQLite 文件不能证明恢复点一致。 @@ -48,9 +58,11 @@ Verification rejects missing, modified, path-escaping, and unlisted files. Resto - manual、unknown outcome、跨订阅周期和外部已接受但本地未完成的结算必须单独对账。 - 新版本写入不保证旧版本可以直接降级;回退使用完整升级前归档,并核对期间新增写入。 - 本工具不验证 MySQL/PostgreSQL、Redis、多主机、真实渠道、OAuth、掉电恢复或浏览器流程。 +- 合成业务快照测试不能替代真实进程启动、真实计费/渠道调用或 MySQL/PostgreSQL 兼容性测试。 - `--quiesced` is mandatory; copying one SQLite file while it is live does not establish a consistent restore point. - An archive is not an atomic snapshot across the business database, log database, and external payment state. - Manual, unknown-outcome, cross-period, and externally accepted but locally incomplete settlements require separate reconciliation. - New-version writes are not guaranteed to support direct downgrade; rollback uses the complete pre-upgrade archive and reconciles writes made during the rehearsal. - This tool does not validate MySQL/PostgreSQL, Redis, multi-host operation, real providers, OAuth, power loss, or browser flows. +- The synthetic business-snapshot test does not replace live-process startup, real billing/provider calls, or MySQL/PostgreSQL compatibility testing. diff --git a/model/sqlite_operations_acceptance_test.go b/model/sqlite_operations_acceptance_test.go new file mode 100644 index 00000000..5f727f4c --- /dev/null +++ b/model/sqlite_operations_acceptance_test.go @@ -0,0 +1,321 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package model + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type sqliteOperationsBusinessSnapshot struct { + User struct { + ID int + Quota int64 + UsedQuota int64 + RequestCount int + } + Token struct { + ID int + UserID int + RemainQuota int64 + UsedQuota int64 + } + Channel struct { + ID int + UsedQuota int64 + } + Plan struct { + ID int + TotalAmount int64 + ResetPeriod string + } + Subscription struct { + ID int + UserID int + PlanID int + AmountTotal int64 + AmountUsed int64 + LastResetAt int64 + Status string + } + PreConsume struct { + RequestID string + UserID int + SubscriptionID int + PreConsumed int64 + Status string + } + Selection struct { + RequestID string + Source string + UserID int + EffectiveQuota int64 + } + Settlement struct { + OperationKey string + Source string + UserID int + FundingDelta int64 + AppliedFundingDelta int64 + TokenDelta int64 + AppliedTokenDelta int64 + Status string + EffectStatus string + Revision int64 + } + Log struct { + ID int + UserID int + RequestID string + Quota int + ModelName string + } + Receipt struct { + OperationKey string + ClaimToken string + } +} + +func TestSQLiteOperationsAcceptancePreservesBusinessSnapshot(t *testing.T) { + t.Parallel() + + root := sqliteOperationsRepoRoot(t) + workDir := t.TempDir() + primaryDir := filepath.Join(workDir, "primary") + logDir := filepath.Join(workDir, "log") + configDir := filepath.Join(workDir, "config") + require.NoError(t, os.MkdirAll(primaryDir, 0o700)) + require.NoError(t, os.MkdirAll(logDir, 0o700)) + require.NoError(t, os.MkdirAll(configDir, 0o700)) + primaryPath := filepath.Join(primaryDir, "ledger.sqlite") + logPath := filepath.Join(logDir, "logs.sqlite") + configPath := filepath.Join(configDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte("database_password: synthetic-only\n"), 0o600)) + + seedSQLiteOperationsDataset(t, primaryPath, logPath) + before := readSQLiteOperationsSnapshot(t, primaryPath, logPath) + + archivePath := filepath.Join(workDir, "archive") + runSQLiteOperationsCLI(t, root, "backup", "--quiesced", "--primary", primaryPath, "--log", logPath, "--config", configPath, "--destination", archivePath) + runSQLiteOperationsCLI(t, root, "verify", "--backup", archivePath) + + firstRestore := filepath.Join(workDir, "restore-first") + secondRestore := filepath.Join(workDir, "restore-rollback") + runSQLiteOperationsCLI(t, root, "restore", "--backup", archivePath, "--target", firstRestore) + runSQLiteOperationsCLI(t, root, "restore", "--backup", archivePath, "--target", secondRestore) + + firstSnapshot := readSQLiteOperationsSnapshot(t, + filepath.Join(firstRestore, "primary", filepath.Base(primaryPath)), + filepath.Join(firstRestore, "log", filepath.Base(logPath)), + ) + // Reopening the restored files models a second cold start and catches + // archives that are readable only while the first connection is open. + secondStartSnapshot := readSQLiteOperationsSnapshot(t, + filepath.Join(firstRestore, "primary", filepath.Base(primaryPath)), + filepath.Join(firstRestore, "log", filepath.Base(logPath)), + ) + rollbackSnapshot := readSQLiteOperationsSnapshot(t, + filepath.Join(secondRestore, "primary", filepath.Base(primaryPath)), + filepath.Join(secondRestore, "log", filepath.Base(logPath)), + ) + + require.Equal(t, before, firstSnapshot, "isolated restore changed the business snapshot") + require.Equal(t, firstSnapshot, secondStartSnapshot, "a repeated cold start changed the business snapshot") + require.Equal(t, firstSnapshot, rollbackSnapshot, "the rollback archive copy diverged from the first restore") + _, err := os.Stat(filepath.Join(firstRestore, filepath.Base(configPath))) + require.ErrorIs(t, err, os.ErrNotExist, "configuration contents must not be restored by sqliteops") +} + +func seedSQLiteOperationsDataset(t *testing.T, primaryPath, logPath string) { + const ( + userID = 91001 + tokenID = 91002 + channelID = 91003 + planID = 91004 + subscriptionID = 91005 + ) + now := int64(1_760_000_000) + requestID := "sqliteops-acceptance-request" + operationKey := "request:sqliteops-acceptance:finalize" + + db := openSQLiteOperationsDatabase(t, primaryPath) + require.NoError(t, db.AutoMigrate( + &User{}, &Token{}, &Channel{}, &SubscriptionPlan{}, &UserSubscription{}, + &SubscriptionPreConsumeRecord{}, &BillingPreConsumeSelection{}, &BillingSettlement{}, + )) + require.NoError(t, db.Create(&User{ + Id: userID, Username: "sqliteops-acceptance", Password: "synthetic-password", + Email: "sqliteops@example.invalid", Quota: 100000, UsedQuota: 2345, + RequestCount: 17, Group: "default", + }).Error) + require.NoError(t, db.Create(&Token{ + Id: tokenID, UserId: userID, Key: "synthetic-token-not-a-secret", Name: "acceptance", + RemainQuota: 87654, UsedQuota: 2346, Status: 1, ExpiredTime: -1, + }).Error) + require.NoError(t, db.Create(&Channel{ + Id: channelID, Type: 1, Key: "synthetic-channel-not-a-secret", Name: "acceptance", + Status: 1, UsedQuota: 4567, Group: "default", + }).Error) + require.NoError(t, db.Create(&SubscriptionPlan{ + Id: planID, Title: "SQLite acceptance plan", Currency: "USD", DurationUnit: "month", + DurationValue: 1, Enabled: true, TotalAmount: 50000, QuotaResetPeriod: SubscriptionResetMonthly, + CreatedAt: now, UpdatedAt: now, + }).Error) + require.NoError(t, db.Create(&UserSubscription{ + Id: subscriptionID, UserId: userID, PlanId: planID, AmountTotal: 50000, + AmountUsed: 1200, StartTime: now - 3600, EndTime: now + 86400, + Status: "active", LastResetTime: now - 3600, NextResetTime: now + 30*86400, + AllowWalletOverflow: true, CreatedAt: now, UpdatedAt: now, + }).Error) + require.NoError(t, db.Create(&SubscriptionPreConsumeRecord{ + RequestId: requestID, UserId: userID, TokenId: tokenID, UserSubscriptionId: subscriptionID, + SubscriptionLastResetTime: now - 3600, ModelName: "synthetic-model", PreConsumed: 300, + Status: "consumed", CreatedAt: now, UpdatedAt: now, + }).Error) + require.NoError(t, db.Create(&BillingPreConsumeSelection{ + RequestID: requestID, Source: BillingSettlementSourceSubscription, UserID: userID, + TokenID: tokenID, ModelName: "synthetic-model", RequestedQuota: 300, + EffectiveQuota: 300, CreatedAt: now, + }).Error) + require.NoError(t, db.Create(&BillingSettlement{ + OperationKey: operationKey, Source: BillingSettlementSourceSubscription, UserID: userID, + SubscriptionID: subscriptionID, TokenID: tokenID, FundingDelta: -300, + AppliedFundingDelta: -300, TokenDelta: 0, AppliedTokenDelta: 0, + Status: BillingSettlementStatusApplied, EffectStatus: BillingSettlementEffectApplied, + Revision: 2, PreConsumeRequestID: requestID, PreConsumeModelName: "synthetic-model", + PreConsumeRequestedQuota: 300, PreConsumeEffectiveQuota: 300, CreatedAt: now, UpdatedAt: now, + }).Error) + require.NoError(t, closeSQLiteOperationsDatabase(db)) + + logDB := openSQLiteOperationsDatabase(t, logPath) + require.NoError(t, logDB.AutoMigrate(&Log{}, &BillingLogReceipt{})) + require.NoError(t, logDB.Create(&Log{ + Id: 91006, UserId: userID, CreatedAt: now, Type: LogTypeConsume, + Username: "sqliteops-acceptance", TokenName: "acceptance", ModelName: "synthetic-model", + Quota: 300, PromptTokens: 10, CompletionTokens: 20, TokenId: tokenID, + ChannelId: channelID, RequestId: requestID, Group: "default", Content: "synthetic log", + }).Error) + require.NoError(t, logDB.Create(&BillingLogReceipt{ + OperationKey: operationKey, ClaimToken: "synthetic-claim-token", CreatedAt: now, + }).Error) + require.NoError(t, closeSQLiteOperationsDatabase(logDB)) +} + +func readSQLiteOperationsSnapshot(t *testing.T, primaryPath, logPath string) sqliteOperationsBusinessSnapshot { + const ( + userID = 91001 + tokenID = 91002 + channelID = 91003 + planID = 91004 + subscriptionID = 91005 + requestID = "sqliteops-acceptance-request" + operationKey = "request:sqliteops-acceptance:finalize" + ) + var snapshot sqliteOperationsBusinessSnapshot + db := openSQLiteOperationsDatabase(t, primaryPath) + var user User + require.NoError(t, db.First(&user, userID).Error) + snapshot.User.ID, snapshot.User.Quota, snapshot.User.UsedQuota, snapshot.User.RequestCount = user.Id, user.Quota, user.UsedQuota, user.RequestCount + var token Token + require.NoError(t, db.First(&token, tokenID).Error) + snapshot.Token.ID, snapshot.Token.UserID, snapshot.Token.RemainQuota, snapshot.Token.UsedQuota = token.Id, token.UserId, token.RemainQuota, token.UsedQuota + var channel Channel + require.NoError(t, db.First(&channel, channelID).Error) + snapshot.Channel.ID, snapshot.Channel.UsedQuota = channel.Id, channel.UsedQuota + var plan SubscriptionPlan + require.NoError(t, db.First(&plan, planID).Error) + snapshot.Plan.ID, snapshot.Plan.TotalAmount, snapshot.Plan.ResetPeriod = plan.Id, plan.TotalAmount, plan.QuotaResetPeriod + var subscription UserSubscription + require.NoError(t, db.First(&subscription, subscriptionID).Error) + snapshot.Subscription.ID, snapshot.Subscription.UserID, snapshot.Subscription.PlanID = subscription.Id, subscription.UserId, subscription.PlanId + snapshot.Subscription.AmountTotal, snapshot.Subscription.AmountUsed = subscription.AmountTotal, subscription.AmountUsed + snapshot.Subscription.LastResetAt, snapshot.Subscription.Status = subscription.LastResetTime, subscription.Status + var preConsume SubscriptionPreConsumeRecord + require.NoError(t, db.Where("request_id = ?", requestID).First(&preConsume).Error) + snapshot.PreConsume.RequestID, snapshot.PreConsume.UserID, snapshot.PreConsume.SubscriptionID = preConsume.RequestId, preConsume.UserId, preConsume.UserSubscriptionId + snapshot.PreConsume.PreConsumed, snapshot.PreConsume.Status = preConsume.PreConsumed, preConsume.Status + var selection BillingPreConsumeSelection + require.NoError(t, db.Where("request_id = ?", requestID).First(&selection).Error) + snapshot.Selection.RequestID, snapshot.Selection.Source, snapshot.Selection.UserID = selection.RequestID, selection.Source, selection.UserID + snapshot.Selection.EffectiveQuota = selection.EffectiveQuota + var settlement BillingSettlement + require.NoError(t, db.Where("operation_key = ?", operationKey).First(&settlement).Error) + snapshot.Settlement.OperationKey, snapshot.Settlement.Source, snapshot.Settlement.UserID = settlement.OperationKey, settlement.Source, settlement.UserID + snapshot.Settlement.FundingDelta, snapshot.Settlement.AppliedFundingDelta = settlement.FundingDelta, settlement.AppliedFundingDelta + snapshot.Settlement.TokenDelta, snapshot.Settlement.AppliedTokenDelta = settlement.TokenDelta, settlement.AppliedTokenDelta + snapshot.Settlement.Status, snapshot.Settlement.EffectStatus, snapshot.Settlement.Revision = settlement.Status, settlement.EffectStatus, settlement.Revision + require.NoError(t, closeSQLiteOperationsDatabase(db)) + + logDB := openSQLiteOperationsDatabase(t, logPath) + var logEntry Log + require.NoError(t, logDB.First(&logEntry, 91006).Error) + snapshot.Log.ID, snapshot.Log.UserID, snapshot.Log.RequestID, snapshot.Log.Quota, snapshot.Log.ModelName = logEntry.Id, logEntry.UserId, logEntry.RequestId, logEntry.Quota, logEntry.ModelName + var receipt BillingLogReceipt + require.NoError(t, logDB.Where("operation_key = ?", operationKey).First(&receipt).Error) + snapshot.Receipt.OperationKey, snapshot.Receipt.ClaimToken = receipt.OperationKey, receipt.ClaimToken + require.NoError(t, closeSQLiteOperationsDatabase(logDB)) + return snapshot +} + +func openSQLiteOperationsDatabase(t *testing.T, path string) *gorm.DB { + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + return db +} + +func closeSQLiteOperationsDatabase(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func sqliteOperationsRepoRoot(t *testing.T) string { + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok) + directory := filepath.Dir(filename) + for { + if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { + return directory + } + parent := filepath.Dir(directory) + if parent == directory { + t.Fatalf("could not find repository root from %s", filename) + } + directory = parent + } +} + +func runSQLiteOperationsCLI(t *testing.T, root string, args ...string) { + commandArgs := append([]string{"run", "./tools/sqliteops"}, args...) + command := exec.Command("go", commandArgs...) + command.Dir = root + output, err := command.CombinedOutput() + require.NoErrorf(t, err, "sqliteops %v failed: %s", args, output) +} From ded588820516564e2e3b3fa588ad2469b906f23b Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 03:45:28 +0800 Subject: [PATCH 14/23] test(mysql): gate startup migration on isolated empty schema --- model/mysql_migration_acceptance_test.go | 103 +++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 model/mysql_migration_acceptance_test.go diff --git a/model/mysql_migration_acceptance_test.go b/model/mysql_migration_acceptance_test.go new file mode 100644 index 00000000..f2b15f0e --- /dev/null +++ b/model/mysql_migration_acceptance_test.go @@ -0,0 +1,103 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package model + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + mysqldriver "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/require" + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var mysqlMigrationAcceptanceDatabase = regexp.MustCompile(`^maxapi_mysql_acceptance_[a-z0-9_]{1,32}$`) + +// TestMySQLStartupMigrationOnEmptyDatabase is opt-in. It intentionally +// refuses a normal application database: migration acceptance must use a +// fresh, disposable schema so a mistaken DSN cannot alter user data. +func TestMySQLStartupMigrationOnEmptyDatabase(t *testing.T) { + dsn := strings.TrimSpace(testEnv("TEST_MYSQL_MIGRATION_DSN")) + if dsn == "" { + t.Skip("set TEST_MYSQL_MIGRATION_DSN to a dedicated disposable MySQL database") + } + + config, err := mysqldriver.ParseDSN(dsn) + require.NoError(t, err) + if !mysqlMigrationAcceptanceDatabase.MatchString(config.DBName) { + t.Fatalf("refusing MySQL migration target with unsafe database name") + } + if config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify) { + t.Fatalf("refusing MySQL migration target without certificate-validating TLS") + } + + db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + sqlDB.SetMaxOpenConns(4) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + var relationCount int + require.NoError(t, sqlDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()").Scan(&relationCount)) + if relationCount != 0 { + t.Fatalf("refusing non-empty MySQL migration target (%d tables)", relationCount) + } + + oldDB, oldLogDB := DB, LOG_DB + oldSQLite, oldMySQL, oldPostgres := common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL + oldRunner := logRetryMarkerBackfillAsyncRunner + DB, LOG_DB = db, db + common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL = false, true, false + logRetryMarkerBackfillAsyncRunner = func(fn func()) { fn() } + initCol() + t.Cleanup(func() { + DB, LOG_DB = oldDB, oldLogDB + common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL = oldSQLite, oldMySQL, oldPostgres + logRetryMarkerBackfillAsyncRunner = oldRunner + initCol() + }) + + require.NoError(t, migrateDB(), "first MySQL startup migration") + require.NoError(t, migrateDB(), "second MySQL startup migration") + for _, model := range []any{ + &User{}, &Token{}, &Channel{}, &BillingSettlement{}, &BillingPreConsumeSelection{}, + &SubscriptionPlan{}, &UserSubscription{}, &SubscriptionPreConsumeRecord{}, &Log{}, &BillingLogReceipt{}, + } { + require.True(t, db.Migrator().HasTable(model), "expected migrated table for %T", model) + } + require.True(t, db.Migrator().HasColumn(&User{}, "normalized_email")) + require.True(t, db.Migrator().HasColumn(&UserSubscription{}, "allow_wallet_overflow")) + require.True(t, db.Migrator().HasColumn(&SubscriptionPlan{}, "allow_wallet_overflow")) + require.True(t, db.Migrator().HasIndex(&BillingSettlement{}, "idx_billing_settlement_reconciliation")) +} + +func testEnv(name string) string { + return strings.TrimSpace(os.Getenv(name)) +} From d451a051b149a05ef04560ca2bf8e06b5135b919 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 10:14:24 +0800 Subject: [PATCH 15/23] test(mysql): allow explicitly named dedicated schema --- model/mysql_migration_acceptance_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/model/mysql_migration_acceptance_test.go b/model/mysql_migration_acceptance_test.go index f2b15f0e..f4f766b5 100644 --- a/model/mysql_migration_acceptance_test.go +++ b/model/mysql_migration_acceptance_test.go @@ -39,7 +39,9 @@ var mysqlMigrationAcceptanceDatabase = regexp.MustCompile(`^maxapi_mysql_accepta // TestMySQLStartupMigrationOnEmptyDatabase is opt-in. It intentionally // refuses a normal application database: migration acceptance must use a -// fresh, disposable schema so a mistaken DSN cannot alter user data. +// fresh, disposable schema so a mistaken DSN cannot alter user data. A +// non-standard schema name requires an explicit TEST_MYSQL_MIGRATION_DATABASE +// value in the same process and is still required to be empty. func TestMySQLStartupMigrationOnEmptyDatabase(t *testing.T) { dsn := strings.TrimSpace(testEnv("TEST_MYSQL_MIGRATION_DSN")) if dsn == "" { @@ -48,7 +50,8 @@ func TestMySQLStartupMigrationOnEmptyDatabase(t *testing.T) { config, err := mysqldriver.ParseDSN(dsn) require.NoError(t, err) - if !mysqlMigrationAcceptanceDatabase.MatchString(config.DBName) { + allowedDatabase := strings.TrimSpace(os.Getenv("TEST_MYSQL_MIGRATION_DATABASE")) + if !mysqlMigrationAcceptanceDatabase.MatchString(config.DBName) && config.DBName != allowedDatabase { t.Fatalf("refusing MySQL migration target with unsafe database name") } if config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify) { From 70ad8756daf0703ef59a0d5fb72a9eaa24dd426b Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 11:49:38 +0800 Subject: [PATCH 16/23] test(mysql): audit initialized schema compatibility --- model/mysql_migration_acceptance_test.go | 72 ++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/model/mysql_migration_acceptance_test.go b/model/mysql_migration_acceptance_test.go index f4f766b5..13d3b89a 100644 --- a/model/mysql_migration_acceptance_test.go +++ b/model/mysql_migration_acceptance_test.go @@ -21,6 +21,12 @@ package model import ( "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "database/sql" + "encoding/hex" + "errors" "os" "regexp" "strings" @@ -37,6 +43,32 @@ import ( var mysqlMigrationAcceptanceDatabase = regexp.MustCompile(`^maxapi_mysql_acceptance_[a-z0-9_]{1,32}$`) +func TestMySQLSchemaCompatibilityReadOnly(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("TEST_MYSQL_SCHEMA_AUDIT_DSN")) + if dsn == "" { + t.Skip("set TEST_MYSQL_SCHEMA_AUDIT_DSN to run the read-only MySQL schema audit") + } + config, err := mysqldriver.ParseDSN(dsn) + require.NoError(t, err) + allowedDatabase := strings.TrimSpace(os.Getenv("TEST_MYSQL_SCHEMA_AUDIT_DATABASE")) + if !mysqlMigrationAcceptanceDatabase.MatchString(config.DBName) && config.DBName != allowedDatabase { + t.Fatalf("refusing MySQL schema audit target with unsafe database name") + } + + db, sqlDB := openMySQLAcceptanceDB(t, dsn) + t.Cleanup(func() { _ = sqlDB.Close() }) + for _, model := range []any{ + &User{}, &Token{}, &Channel{}, &Option{}, &BillingSettlement{}, &BillingPreConsumeSelection{}, + &SubscriptionPlan{}, &UserSubscription{}, &SubscriptionPreConsumeRecord{}, &Log{}, &BillingLogReceipt{}, + } { + require.True(t, db.Migrator().HasTable(model), "expected existing table for %T", model) + } + require.True(t, db.Migrator().HasColumn(&User{}, "normalized_email")) + require.True(t, db.Migrator().HasColumn(&UserSubscription{}, "allow_wallet_overflow")) + require.True(t, db.Migrator().HasColumn(&SubscriptionPlan{}, "allow_wallet_overflow")) + require.True(t, db.Migrator().HasIndex(&BillingSettlement{}, "idx_billing_settlement_reconciliation")) +} + // TestMySQLStartupMigrationOnEmptyDatabase is opt-in. It intentionally // refuses a normal application database: migration acceptance must use a // fresh, disposable schema so a mistaken DSN cannot alter user data. A @@ -54,14 +86,11 @@ func TestMySQLStartupMigrationOnEmptyDatabase(t *testing.T) { if !mysqlMigrationAcceptanceDatabase.MatchString(config.DBName) && config.DBName != allowedDatabase { t.Fatalf("refusing MySQL migration target with unsafe database name") } - if config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify) { + if strings.TrimSpace(os.Getenv("TEST_MYSQL_TLS_CERT_SHA256")) == "" && config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify) { t.Fatalf("refusing MySQL migration target without certificate-validating TLS") } - db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - sqlDB, err := db.DB() - require.NoError(t, err) + db, sqlDB := openMySQLAcceptanceDB(t, dsn) t.Cleanup(func() { _ = sqlDB.Close() }) sqlDB.SetMaxOpenConns(4) @@ -104,3 +133,36 @@ func TestMySQLStartupMigrationOnEmptyDatabase(t *testing.T) { func testEnv(name string) string { return strings.TrimSpace(os.Getenv(name)) } + +func openMySQLAcceptanceDB(t *testing.T, dsn string) (*gorm.DB, *sql.DB) { + t.Helper() + config, err := mysqldriver.ParseDSN(dsn) + require.NoError(t, err) + if fingerprint := strings.TrimSpace(os.Getenv("TEST_MYSQL_TLS_CERT_SHA256")); fingerprint != "" { + expected, err := hex.DecodeString(fingerprint) + require.NoError(t, err) + require.Len(t, expected, sha256.Size) + config.TLS = &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("mysql server returned no certificate") + } + actual := sha256.Sum256(state.PeerCertificates[0].Raw) + if !hmac.Equal(actual[:], expected) { + return errors.New("mysql certificate pin mismatch") + } + return nil + }, + } + } else if config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify) { + t.Fatalf("refusing MySQL target without certificate-validating TLS") + } + connector, err := mysqldriver.NewConnector(config) + require.NoError(t, err) + sqlDB := sql.OpenDB(connector) + db, err := gorm.Open(gormmysql.New(gormmysql.Config{Conn: sqlDB}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + return db, sqlDB +} From 94a017de5d23a9071fcbba28ad2ad439a1b7ce66 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 13:36:08 +0800 Subject: [PATCH 17/23] test(mysql): pin TLS for external billing contract --- model/task_billing_external_test.go | 30 ++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/model/task_billing_external_test.go b/model/task_billing_external_test.go index cf9076e8..324bb5de 100644 --- a/model/task_billing_external_test.go +++ b/model/task_billing_external_test.go @@ -2,7 +2,11 @@ package model import ( "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" "database/sql" + "encoding/hex" "errors" "flag" "fmt" @@ -86,9 +90,33 @@ func openTaskBillingTestSQL(dialect, dsn string) (*sql.DB, string, error) { // plaintext network connection. Unix sockets are local-only; every // other network type (including tcp4/tcp6) requires certificate- // validating TLS and must not permit plaintext fallback. - if config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify || config.AllowFallbackToPlaintext) { + if config.Net != "unix" && config.AllowFallbackToPlaintext { return nil, "", errTaskBillingUnsafeDatabase } + if config.Net != "unix" { + if fingerprint := strings.TrimSpace(os.Getenv("TEST_MYSQL_TLS_CERT_SHA256")); fingerprint != "" { + expected, err := hex.DecodeString(fingerprint) + if err != nil || len(expected) != sha256.Size { + return nil, "", errTaskBillingUnsafeDatabase + } + config.TLS = &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("mysql server returned no certificate") + } + actual := sha256.Sum256(state.PeerCertificates[0].Raw) + if !hmac.Equal(actual[:], expected) { + return errors.New("mysql certificate pin mismatch") + } + return nil + }, + } + } else if config.TLS == nil || config.TLS.InsecureSkipVerify { + return nil, "", errTaskBillingUnsafeDatabase + } + } config.Timeout, config.ReadTimeout, config.WriteTimeout = 5*time.Second, 10*time.Second, 10*time.Second connector, err := mysqldriver.NewConnector(config) if err != nil { From 9cae0574e7f5217ea760827d0e316492e7605e32 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 14:04:22 +0800 Subject: [PATCH 18/23] feat(mysql): support pinned TLS certificates --- model/main.go | 55 +++++++++++++++++++++++++++++++++++ model/mysql_tls_test.go | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 model/mysql_tls_test.go diff --git a/model/main.go b/model/main.go index d06027ad..3e030ad6 100644 --- a/model/main.go +++ b/model/main.go @@ -2,10 +2,15 @@ package model import ( "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" "database/sql" + "encoding/hex" "errors" "fmt" "log" + "net/url" "os" "strings" "sync" @@ -16,6 +21,7 @@ import ( "github.com/bytedance/gopkg/util/gopool" "github.com/glebarez/sqlite" + mysqldriver "github.com/go-sql-driver/mysql" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -219,6 +225,11 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, error) { } // Use MySQL common.SysLog("using MySQL as database") + pinnedDSN, err := mysqlDSNWithPinnedCertificate(dsn) + if err != nil { + return nil, err + } + dsn = pinnedDSN // check parseTime if !strings.Contains(dsn, "parseTime") { if strings.Contains(dsn, "?") { @@ -248,6 +259,50 @@ func chooseDB(envName string, isLog bool) (*gorm.DB, error) { }) } +var mysqlPinnedTLSConfigs sync.Map + +func mysqlDSNWithPinnedCertificate(dsn string) (string, error) { + fingerprint := strings.TrimSpace(os.Getenv("MYSQL_TLS_CERT_SHA256")) + if fingerprint == "" { + return dsn, nil + } + expected, err := hex.DecodeString(fingerprint) + if err != nil || len(expected) != sha256.Size { + return "", errors.New("MYSQL_TLS_CERT_SHA256 must be a 64-character SHA-256 fingerprint") + } + configName := "max-api-mysql-pin-" + hex.EncodeToString(expected[:8]) + if _, loaded := mysqlPinnedTLSConfigs.LoadOrStore(configName, struct{}{}); !loaded { + config := &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("mysql server returned no certificate") + } + actual := sha256.Sum256(state.PeerCertificates[0].Raw) + if !hmac.Equal(actual[:], expected) { + return errors.New("mysql certificate pin mismatch") + } + return nil + }, + } + if err := mysqldriver.RegisterTLSConfig(configName, config); err != nil { + mysqlPinnedTLSConfigs.Delete(configName) + return "", fmt.Errorf("register MySQL certificate pin: %w", err) + } + } + base, rawQuery, hasQuery := strings.Cut(dsn, "?") + values, err := url.ParseQuery(rawQuery) + if err != nil { + return "", fmt.Errorf("parse MySQL DSN query: %w", err) + } + values.Set("tls", configName) + if !hasQuery { + return base + "?" + values.Encode(), nil + } + return base + "?" + values.Encode(), nil +} + func InitDB() (err error) { db, err := chooseDB("SQL_DSN", false) if err == nil { diff --git a/model/mysql_tls_test.go b/model/mysql_tls_test.go new file mode 100644 index 00000000..bd189d2c --- /dev/null +++ b/model/mysql_tls_test.go @@ -0,0 +1,63 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package model + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMySQLDSNWithPinnedCertificateLeavesDefaultUnchanged(t *testing.T) { + t.Setenv("MYSQL_TLS_CERT_SHA256", "") + dsn := "user:password@tcp(127.0.0.1:3306)/database?parseTime=true" + got, err := mysqlDSNWithPinnedCertificate(dsn) + require.NoError(t, err) + require.Equal(t, dsn, got) +} + +func TestMySQLDSNWithPinnedCertificateRejectsInvalidFingerprint(t *testing.T) { + t.Setenv("MYSQL_TLS_CERT_SHA256", "not-a-fingerprint") + _, err := mysqlDSNWithPinnedCertificate("user:password@tcp(127.0.0.1:3306)/database") + require.EqualError(t, err, "MYSQL_TLS_CERT_SHA256 must be a 64-character SHA-256 fingerprint") +} + +func TestMySQLDSNWithPinnedCertificateAddsReusableTLSConfig(t *testing.T) { + fingerprint := strings.Repeat("a", 64) + t.Setenv("MYSQL_TLS_CERT_SHA256", fingerprint) + dsn := "user:password@tcp(127.0.0.1:3306)/database?parseTime=true" + got, err := mysqlDSNWithPinnedCertificate(dsn) + require.NoError(t, err) + require.Contains(t, got, "parseTime=true") + require.Contains(t, got, "tls=max-api-mysql-pin-aaaaaaaaaaaaaaaa") + + reused, err := mysqlDSNWithPinnedCertificate(dsn) + require.NoError(t, err) + require.Equal(t, got, reused) +} + +func TestMySQLDSNWithPinnedCertificateReplacesUnpinnedTLSOption(t *testing.T) { + t.Setenv("MYSQL_TLS_CERT_SHA256", strings.Repeat("b", 64)) + got, err := mysqlDSNWithPinnedCertificate("user:password@tcp(127.0.0.1:3306)/database?tls=skip-verify&parseTime=true") + require.NoError(t, err) + require.Contains(t, got, "tls=max-api-mysql-pin-bbbbbbbbbbbbbbbb") + require.NotContains(t, got, "tls=skip-verify") +} From b7337d0e645bdf4727691cdc7eeccad3f214e562 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 19:40:48 +0800 Subject: [PATCH 19/23] test(mysql): allow explicitly authorized billing schema --- model/task_billing_external_test.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/model/task_billing_external_test.go b/model/task_billing_external_test.go index 324bb5de..15035e4e 100644 --- a/model/task_billing_external_test.go +++ b/model/task_billing_external_test.go @@ -68,6 +68,8 @@ func taskBillingTestCheck(t *testing.T, err error, stage string) { } // Each external invocation needs a freshly provisioned disposable database. +// A non-standard name is accepted only when the same process explicitly sets +// TEST_TASK_BILLING_MYSQL_DATABASE to that exact database name. // Nothing is dropped: successful and partial migrations are retained for audit. // The unique ownership table rejects a second harness targeting the same DB. type taskBillingTestOwnership struct { @@ -83,7 +85,8 @@ func openTaskBillingTestSQL(dialect, dsn string) (*sql.DB, string, error) { if err != nil { return nil, "", err } - if !taskBillingTestDatabaseName.MatchString(config.DBName) { + allowedDatabase := strings.TrimSpace(os.Getenv("TEST_TASK_BILLING_MYSQL_DATABASE")) + if !taskBillingTestDatabaseName.MatchString(config.DBName) && config.DBName != allowedDatabase { return nil, "", errTaskBillingUnsafeDatabase } // External contract tests may never put database credentials on a @@ -168,6 +171,21 @@ func TestOpenTaskBillingTestSQLRejectsPlaintextNetworkVariants(t *testing.T) { } } +func TestOpenTaskBillingTestSQLAllowsExplicitDedicatedDatabase(t *testing.T) { + t.Setenv("TEST_TASK_BILLING_MYSQL_DATABASE", "test3") + t.Setenv("TEST_MYSQL_TLS_CERT_SHA256", strings.Repeat("0", 64)) + db, name, err := openTaskBillingTestSQL("mysql", "user:password@tcp(127.0.0.1:3306)/test3") + if err != nil { + t.Fatalf("expected explicitly authorized database name to pass validation: %v", err) + } + if name != "test3" { + t.Fatalf("expected database name test3, got %q", name) + } + if err := db.Close(); err != nil { + t.Fatalf("close validated database handle: %v", err) + } +} + func taskBillingPostgresSSLMode(dsn string) string { if parsed, err := url.Parse(dsn); err == nil && parsed.Scheme != "" { return strings.ToLower(strings.TrimSpace(parsed.Query().Get("sslmode"))) From e5cc71d2e80677fde78b9f296f7b2e27299586dc Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 19:52:50 +0800 Subject: [PATCH 20/23] feat(taskplugin): validate plugin manifest contract --- pkg/taskplugin/manifest.go | 327 ++++++++++++++++++++++++++++++++ pkg/taskplugin/manifest_test.go | 99 ++++++++++ 2 files changed, 426 insertions(+) create mode 100644 pkg/taskplugin/manifest.go create mode 100644 pkg/taskplugin/manifest_test.go diff --git a/pkg/taskplugin/manifest.go b/pkg/taskplugin/manifest.go new file mode 100644 index 00000000..1d716e27 --- /dev/null +++ b/pkg/taskplugin/manifest.go @@ -0,0 +1,327 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package taskplugin + +import ( + "fmt" + "net/url" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +const APIVersion = 1 + +var ( + manifestKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,29}$`) + semverPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + identifierPattern = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) + allowedRouteMethods = map[string]struct{}{"GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {}} + allowedRouteTypes = map[string]struct{}{"submit": {}, "query": {}, "dynamic": {}} + allowedProtocolNames = map[string]struct{}{"openai_responses": {}, "openai_video": {}, "openai_image": {}} + allowedProtocolModes = map[string]struct{}{"stream": {}, "sync": {}, "background": {}} + allowedUsageTypes = map[string]struct{}{"number": {}, "boolean": {}, "enum": {}} + allowedUsageUnits = map[string]struct{}{"second": {}, "count": {}, "token": {}, "credit": {}} +) + +// Manifest is the host-owned portion of the Task Plugin v1 contract. It is +// deliberately data-only: executing plugin code belongs to a later sandboxed +// driver layer and is not part of validation or registry publication. +type Manifest struct { + APIVersion int + Key string + Name string + Version string + Models []string + Protocols []ProtocolClaim + Routes []Route + BaseURL string + AllowedHosts []string + Upstreams []string + UsageSchema map[string]UsageField +} + +type ProtocolClaim struct { + Name string + Supports []string + Bare bool +} + +type Route struct { + Method string + Path string + Type string + Decode string + Render string + TaskIDParam string + Models []string + RetainResult *bool +} + +type UsageField struct { + Type string + Unit string + Enum []string +} + +func ValidateManifest(manifest Manifest) error { + if manifest.APIVersion != APIVersion { + return fmt.Errorf("unsupported task plugin api version %d", manifest.APIVersion) + } + if !manifestKeyPattern.MatchString(manifest.Key) { + return fmt.Errorf("invalid task plugin key %q", manifest.Key) + } + if strings.TrimSpace(manifest.Name) == "" { + return fmt.Errorf("task plugin name is required") + } + if !semverPattern.MatchString(manifest.Version) { + return fmt.Errorf("invalid task plugin version %q", manifest.Version) + } + modelSet, err := validateModels(manifest.Models) + if err != nil { + return err + } + if err := validateProtocols(manifest.Protocols); err != nil { + return err + } + if err := validateRoutes(manifest.Routes, modelSet); err != nil { + return err + } + if err := validateHTTPBaseURL(manifest.BaseURL); err != nil { + return err + } + if err := validateAllowedHosts(manifest.AllowedHosts); err != nil { + return err + } + if err := validateUpstreams(manifest.Upstreams); err != nil { + return err + } + return validateUsageSchema(manifest.UsageSchema) +} + +func validateModels(models []string) (map[string]struct{}, error) { + if len(models) == 0 { + return nil, fmt.Errorf("task plugin must declare at least one model") + } + set := make(map[string]struct{}, len(models)) + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" { + return nil, fmt.Errorf("task plugin model must not be empty") + } + if _, exists := set[model]; exists { + return nil, fmt.Errorf("duplicate task plugin model %q", model) + } + set[model] = struct{}{} + } + return set, nil +} + +func validateProtocols(protocols []ProtocolClaim) error { + seen := make(map[string]struct{}, len(protocols)) + for _, protocol := range protocols { + if _, ok := allowedProtocolNames[protocol.Name]; !ok { + return fmt.Errorf("unsupported task plugin protocol %q", protocol.Name) + } + if _, exists := seen[protocol.Name]; exists { + return fmt.Errorf("duplicate task plugin protocol %q", protocol.Name) + } + seen[protocol.Name] = struct{}{} + if protocol.Bare { + if protocol.Name == "openai_responses" || len(protocol.Supports) > 0 { + return fmt.Errorf("bare task plugin protocol %q cannot declare modes", protocol.Name) + } + continue + } + if protocol.Name == "openai_responses" && len(protocol.Supports) == 0 { + return fmt.Errorf("task plugin protocol %q requires supports", protocol.Name) + } + modes := make(map[string]struct{}, len(protocol.Supports)) + for _, mode := range protocol.Supports { + if _, ok := allowedProtocolModes[mode]; !ok { + return fmt.Errorf("unsupported task plugin protocol mode %q", mode) + } + if _, exists := modes[mode]; exists { + return fmt.Errorf("duplicate task plugin protocol mode %q", mode) + } + modes[mode] = struct{}{} + } + } + return nil +} + +func validateRoutes(routes []Route, models map[string]struct{}) error { + seen := make(map[string]struct{}, len(routes)) + for _, route := range routes { + method := strings.ToUpper(strings.TrimSpace(route.Method)) + if _, ok := allowedRouteMethods[method]; !ok { + return fmt.Errorf("unsupported task plugin route method %q", route.Method) + } + if _, ok := allowedRouteTypes[route.Type]; !ok { + return fmt.Errorf("unsupported task plugin route type %q", route.Type) + } + shape, err := routeShape(route.Path) + if err != nil { + return err + } + conflictKey := method + " " + shape + if _, exists := seen[conflictKey]; exists { + return fmt.Errorf("duplicate task plugin route %q", conflictKey) + } + seen[conflictKey] = struct{}{} + if route.Type == "query" { + if route.Decode != "" { + return fmt.Errorf("query route %q must not declare decode", route.Path) + } + if !identifierPattern.MatchString(route.Render) { + return fmt.Errorf("query route %q requires a valid render hook", route.Path) + } + } else { + if !identifierPattern.MatchString(route.Decode) || !identifierPattern.MatchString(route.Render) { + return fmt.Errorf("route %q requires valid decode and render hooks", route.Path) + } + } + routeModels := make(map[string]struct{}, len(route.Models)) + for _, model := range route.Models { + if _, ok := models[model]; !ok { + return fmt.Errorf("route %q references undeclared model %q", route.Path, model) + } + if _, exists := routeModels[model]; exists { + return fmt.Errorf("route %q declares duplicate model %q", route.Path, model) + } + routeModels[model] = struct{}{} + } + } + return nil +} + +func routeShape(rawPath string) (string, error) { + if rawPath == "" || !strings.HasPrefix(rawPath, "/") || strings.ContainsAny(rawPath, "?#") { + return "", fmt.Errorf("invalid task plugin route path %q", rawPath) + } + parsed, err := url.ParseRequestURI(rawPath) + if err != nil || parsed.Path != rawPath || strings.Contains(rawPath, "//") { + return "", fmt.Errorf("invalid task plugin route path %q", rawPath) + } + parts := strings.Split(rawPath, "/") + for index, part := range parts { + if strings.HasPrefix(part, ":") { + if len(part) == 1 || !identifierPattern.MatchString(part[1:]) { + return "", fmt.Errorf("invalid task plugin route parameter in %q", rawPath) + } + parts[index] = ":param" + } else if strings.Contains(part, "*") { + return "", fmt.Errorf("wildcard task plugin route is not allowed in %q", rawPath) + } + } + return strings.Join(parts, "/"), nil +} + +func validateHTTPBaseURL(rawURL string) error { + if rawURL == "" { + return nil + } + parsed, err := url.Parse(rawURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" { + return fmt.Errorf("invalid task plugin base url") + } + if !isASCII(parsed.Host) { + return fmt.Errorf("task plugin base url host must be ascii") + } + return nil +} + +func validateAllowedHosts(hosts []string) error { + seen := make(map[string]struct{}, len(hosts)) + for _, host := range hosts { + if host == "" || strings.ContainsAny(host, "\\/ ") || strings.IndexFunc(host, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) >= 0 { + return fmt.Errorf("invalid task plugin allowed host %q", host) + } + parsed, err := url.Parse("//" + host) + if err != nil || parsed.Host != host || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("invalid task plugin allowed host %q", host) + } + if _, exists := seen[strings.ToLower(host)]; exists { + return fmt.Errorf("duplicate task plugin allowed host %q", host) + } + seen[strings.ToLower(host)] = struct{}{} + } + return nil +} + +func validateUpstreams(upstreams []string) error { + seen := make(map[string]struct{}, len(upstreams)) + for _, upstream := range upstreams { + if upstream != "vendor" && upstream != "new_api" { + return fmt.Errorf("unsupported task plugin upstream %q", upstream) + } + if _, exists := seen[upstream]; exists { + return fmt.Errorf("duplicate task plugin upstream %q", upstream) + } + seen[upstream] = struct{}{} + } + return nil +} + +func validateUsageSchema(schema map[string]UsageField) error { + for name, field := range schema { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("task plugin usage field name must not be empty") + } + if _, ok := allowedUsageTypes[field.Type]; !ok { + return fmt.Errorf("unsupported task plugin usage field type %q", field.Type) + } + if field.Type == "number" { + if _, ok := allowedUsageUnits[field.Unit]; !ok { + return fmt.Errorf("unsupported task plugin usage unit %q", field.Unit) + } + if len(field.Enum) > 0 { + return fmt.Errorf("task plugin numeric usage field %q must not declare enum values", name) + } + } else if field.Type == "enum" { + if field.Unit != "" || len(field.Enum) == 0 { + return fmt.Errorf("task plugin enum usage field %q requires values and no unit", name) + } + seen := make(map[string]struct{}, len(field.Enum)) + for _, value := range field.Enum { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("task plugin enum usage field %q contains an empty value", name) + } + if _, exists := seen[value]; exists { + return fmt.Errorf("task plugin enum usage field %q contains duplicate value %q", name, value) + } + seen[value] = struct{}{} + } + } else if field.Unit != "" || len(field.Enum) > 0 { + return fmt.Errorf("task plugin usage field %q must not declare a unit or enum values", name) + } + } + return nil +} + +func isASCII(value string) bool { + for _, r := range value { + if r > 127 || !utf8.ValidString(value) { + return false + } + } + return true +} diff --git a/pkg/taskplugin/manifest_test.go b/pkg/taskplugin/manifest_test.go new file mode 100644 index 00000000..4a4daeb7 --- /dev/null +++ b/pkg/taskplugin/manifest_test.go @@ -0,0 +1,99 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package taskplugin + +import ( + "strings" + "testing" +) + +func validManifest() Manifest { + return Manifest{ + APIVersion: 1, + Key: "vendor", + Name: "Vendor", + Version: "1.0.0", + Models: []string{"vendor-model"}, + Protocols: []ProtocolClaim{{Name: "openai_responses", Supports: []string{"stream", "sync", "background"}}, {Name: "openai_video", Bare: true}}, + Routes: []Route{ + {Method: "POST", Path: "/vendor/v1/jobs", Type: "submit", Decode: "createJob", Render: "jobCreated"}, + {Method: "GET", Path: "/vendor/v1/jobs/:task_id", Type: "query", Render: "jobStatus"}, + }, + BaseURL: "https://api.vendor.example", + AllowedHosts: []string{"api.vendor.example:443"}, + Upstreams: []string{"vendor", "new_api"}, + UsageSchema: map[string]UsageField{"duration": {Type: "number", Unit: "second"}, "resolution": {Type: "enum", Enum: []string{"720p", "1080p"}}}, + } +} + +func TestValidateManifestAcceptsVersionedProtocolContract(t *testing.T) { + if err := ValidateManifest(validManifest()); err != nil { + t.Fatalf("valid manifest rejected: %v", err) + } +} + +func TestValidateManifestRejectsRouteShapeConflict(t *testing.T) { + manifest := validManifest() + manifest.Routes = append(manifest.Routes, Route{ + Method: "GET", Path: "/vendor/v1/jobs/:id", Type: "query", Render: "jobStatus", + }) + if err := ValidateManifest(manifest); err == nil || !strings.Contains(err.Error(), "duplicate task plugin route") { + t.Fatalf("expected normalized route conflict, got %v", err) + } +} + +func TestValidateManifestRejectsUnsafeBaseURLAndHost(t *testing.T) { + manifest := validManifest() + manifest.BaseURL = "https://user:password@api.vendor.example" + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected base URL credentials to be rejected") + } + manifest = validManifest() + manifest.AllowedHosts = []string{"https://api.vendor.example/path"} + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected allowed-host URL form to be rejected") + } +} + +func TestValidateManifestRejectsInvalidProtocolAndUsage(t *testing.T) { + manifest := validManifest() + manifest.Protocols[0].Supports = []string{"stream", "stream"} + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected duplicate protocol mode to be rejected") + } + manifest = validManifest() + manifest.UsageSchema["duration"] = UsageField{Type: "number", Unit: "usd"} + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected unsupported usage unit to be rejected") + } + manifest = validManifest() + manifest.UsageSchema["resolution"] = UsageField{Type: "enum", Enum: []string{"720p", "720p"}} + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected duplicate enum value to be rejected") + } +} + +func TestValidateManifestRejectsUndeclaredRouteModel(t *testing.T) { + manifest := validManifest() + manifest.Routes[0].Models = []string{"missing-model"} + if err := ValidateManifest(manifest); err == nil { + t.Fatal("expected undeclared route model to be rejected") + } +} From d9d7360c250852608f0e39a3cc654dd607909ba1 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 20:07:08 +0800 Subject: [PATCH 21/23] feat(taskplugin): add atomic plugin registry --- pkg/taskplugin/registry.go | 265 ++++++++++++++++++++++++++++++++ pkg/taskplugin/registry_test.go | 120 +++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 pkg/taskplugin/registry.go create mode 100644 pkg/taskplugin/registry_test.go diff --git a/pkg/taskplugin/registry.go b/pkg/taskplugin/registry.go new file mode 100644 index 00000000..55923d51 --- /dev/null +++ b/pkg/taskplugin/registry.go @@ -0,0 +1,265 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package taskplugin + +import ( + "fmt" + "net/url" + "sort" + "strings" + "sync/atomic" +) + +type Registry struct { + state atomic.Pointer[registryState] +} + +type registryState struct { + generation uint64 + plugins map[string]Manifest + routes map[string]routeBinding + byMethod map[string][]routeBinding + byModel map[string][]string +} + +type Snapshot struct { + state *registryState +} + +type RouteMatch struct { + PluginKey string + Manifest Manifest + Route Route +} + +func NewRegistry() *Registry { + registry := &Registry{} + registry.state.Store(®istryState{ + plugins: map[string]Manifest{}, + routes: map[string]routeBinding{}, + byMethod: map[string][]routeBinding{}, + byModel: map[string][]string{}, + }) + return registry +} + +func (registry *Registry) Publish(manifest Manifest) (uint64, error) { + if registry == nil { + return 0, fmt.Errorf("task plugin registry is nil") + } + if err := ValidateManifest(manifest); err != nil { + return 0, err + } + manifest = cloneManifest(manifest) + for { + current := registry.state.Load() + if _, exists := current.plugins[manifest.Key]; exists { + return 0, fmt.Errorf("task plugin %q is already published", manifest.Key) + } + next := cloneState(current) + next.generation++ + next.plugins[manifest.Key] = manifest + for _, route := range manifest.Routes { + shape, _ := routeShape(route.Path) + method := strings.ToUpper(strings.TrimSpace(route.Method)) + key := method + " " + shape + if existing, exists := current.routes[key]; exists { + return 0, fmt.Errorf("task plugin route %s conflicts with plugin %q", key, existing.pluginKey) + } + binding := routeBinding{pluginKey: manifest.Key, route: cloneRoute(route)} + next.routes[key] = binding + next.byMethod[method] = append(next.byMethod[method], binding) + } + for _, model := range manifest.Models { + next.byModel[model] = append(next.byModel[model], manifest.Key) + sort.Strings(next.byModel[model]) + } + for method := range next.byMethod { + sort.SliceStable(next.byMethod[method], func(i, j int) bool { + return routeSpecificity(next.byMethod[method][i].route.Path) > routeSpecificity(next.byMethod[method][j].route.Path) + }) + } + if registry.state.CompareAndSwap(current, next) { + return next.generation, nil + } + } +} + +func (registry *Registry) Snapshot() Snapshot { + if registry == nil { + return Snapshot{} + } + return Snapshot{state: registry.state.Load()} +} + +func (snapshot Snapshot) Generation() uint64 { + if snapshot.state == nil { + return 0 + } + return snapshot.state.generation +} + +func (snapshot Snapshot) Plugin(key string) (Manifest, bool) { + if snapshot.state == nil { + return Manifest{}, false + } + manifest, ok := snapshot.state.plugins[key] + if !ok { + return Manifest{}, false + } + return cloneManifest(manifest), true +} + +func (snapshot Snapshot) ModelCandidates(model string) []Manifest { + if snapshot.state == nil { + return nil + } + keys := snapshot.state.byModel[model] + result := make([]Manifest, 0, len(keys)) + for _, key := range keys { + if manifest, ok := snapshot.state.plugins[key]; ok { + result = append(result, cloneManifest(manifest)) + } + } + return result +} + +func (snapshot Snapshot) MatchRoute(method, requestPath, model string) (RouteMatch, bool) { + if snapshot.state == nil { + return RouteMatch{}, false + } + method = strings.ToUpper(strings.TrimSpace(method)) + for _, binding := range snapshot.state.byMethod[method] { + if !routeMatches(binding.route.Path, requestPath) || !routeAllowsModel(binding.route, model) { + continue + } + manifest, ok := snapshot.state.plugins[binding.pluginKey] + if !ok { + continue + } + return RouteMatch{PluginKey: binding.pluginKey, Manifest: cloneManifest(manifest), Route: cloneRoute(binding.route)}, true + } + return RouteMatch{}, false +} + +type routeBinding struct { + pluginKey string + route Route +} + +func cloneState(source *registryState) *registryState { + next := ®istryState{ + generation: source.generation, + plugins: make(map[string]Manifest, len(source.plugins)+1), + routes: make(map[string]routeBinding, len(source.routes)+1), + byMethod: make(map[string][]routeBinding, len(source.byMethod)), + byModel: make(map[string][]string, len(source.byModel)), + } + for key, manifest := range source.plugins { + next.plugins[key] = manifest + } + for key, binding := range source.routes { + next.routes[key] = binding + } + for method, bindings := range source.byMethod { + next.byMethod[method] = append([]routeBinding(nil), bindings...) + } + for model, keys := range source.byModel { + next.byModel[model] = append([]string(nil), keys...) + } + return next +} + +func routeAllowsModel(route Route, model string) bool { + if len(route.Models) == 0 { + return true + } + for _, allowed := range route.Models { + if allowed == model { + return true + } + } + return false +} + +func routeMatches(pattern, requestPath string) bool { + parsed, err := url.ParseRequestURI(requestPath) + if err != nil || parsed.Path != requestPath { + return false + } + patternParts := strings.Split(pattern, "/") + requestParts := strings.Split(requestPath, "/") + if len(patternParts) != len(requestParts) { + return false + } + for index, part := range patternParts { + if strings.HasPrefix(part, ":") { + if requestParts[index] == "" { + return false + } + continue + } + if part != requestParts[index] { + return false + } + } + return true +} + +func routeSpecificity(path string) int { + score := 0 + for _, part := range strings.Split(path, "/") { + if part != "" && !strings.HasPrefix(part, ":") { + score++ + } + } + return score +} + +func cloneManifest(manifest Manifest) Manifest { + manifest.Models = append([]string(nil), manifest.Models...) + manifest.Protocols = append([]ProtocolClaim(nil), manifest.Protocols...) + for index := range manifest.Protocols { + manifest.Protocols[index].Supports = append([]string(nil), manifest.Protocols[index].Supports...) + } + manifest.Routes = append([]Route(nil), manifest.Routes...) + for index := range manifest.Routes { + manifest.Routes[index] = cloneRoute(manifest.Routes[index]) + } + manifest.AllowedHosts = append([]string(nil), manifest.AllowedHosts...) + manifest.Upstreams = append([]string(nil), manifest.Upstreams...) + if manifest.UsageSchema != nil { + manifest.UsageSchema = make(map[string]UsageField, len(manifest.UsageSchema)) + for key, field := range manifest.UsageSchema { + field.Enum = append([]string(nil), field.Enum...) + manifest.UsageSchema[key] = field + } + } + return manifest +} + +func cloneRoute(route Route) Route { + route.Models = append([]string(nil), route.Models...) + if route.RetainResult != nil { + value := *route.RetainResult + route.RetainResult = &value + } + return route +} diff --git a/pkg/taskplugin/registry_test.go b/pkg/taskplugin/registry_test.go new file mode 100644 index 00000000..a968afe5 --- /dev/null +++ b/pkg/taskplugin/registry_test.go @@ -0,0 +1,120 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package taskplugin + +import ( + "fmt" + "sync" + "testing" +) + +func registryManifest(key string, route string) Manifest { + manifest := validManifest() + manifest.Key = key + manifest.Name = key + manifest.Routes = []Route{{Method: "GET", Path: route, Type: "query", Render: "jobStatus"}} + return manifest +} + +func TestRegistryPublishesAtomicSnapshotAndMatchesRoute(t *testing.T) { + registry := NewRegistry() + firstGeneration, err := registry.Publish(registryManifest("alpha", "/alpha/jobs/:task_id")) + if err != nil { + t.Fatalf("publish first manifest: %v", err) + } + before := registry.Snapshot() + if before.Generation() != firstGeneration { + t.Fatalf("expected generation %d, got %d", firstGeneration, before.Generation()) + } + if _, ok := before.MatchRoute("GET", "/beta/jobs/1", "vendor-model"); ok { + t.Fatal("unexpected route match before second publication") + } + secondGeneration, err := registry.Publish(registryManifest("beta", "/beta/jobs/:task_id")) + if err != nil { + t.Fatalf("publish second manifest: %v", err) + } + if secondGeneration != firstGeneration+1 { + t.Fatalf("expected generation %d, got %d", firstGeneration+1, secondGeneration) + } + if _, ok := before.Plugin("beta"); ok { + t.Fatal("old snapshot observed a later publication") + } + match, ok := registry.Snapshot().MatchRoute("GET", "/beta/jobs/1", "vendor-model") + if !ok || match.PluginKey != "beta" || match.Route.Path != "/beta/jobs/:task_id" { + t.Fatalf("unexpected route match: %#v, %v", match, ok) + } +} + +func TestRegistryRejectsDuplicatePluginAndRouteConflict(t *testing.T) { + registry := NewRegistry() + if _, err := registry.Publish(registryManifest("alpha", "/jobs/:task_id")); err != nil { + t.Fatalf("publish first manifest: %v", err) + } + if _, err := registry.Publish(registryManifest("alpha", "/other/:id")); err == nil { + t.Fatal("expected duplicate plugin key to be rejected") + } + if _, err := registry.Publish(registryManifest("beta", "/jobs/:id")); err == nil { + t.Fatal("expected normalized route conflict to be rejected") + } +} + +func TestRegistryIndexesModelCandidatesInStableOrder(t *testing.T) { + registry := NewRegistry() + for _, key := range []string{"zeta", "alpha", "middle"} { + manifest := registryManifest(key, "/"+key) + if _, err := registry.Publish(manifest); err != nil { + t.Fatalf("publish %s: %v", key, err) + } + } + candidates := registry.Snapshot().ModelCandidates("vendor-model") + if len(candidates) != 3 || candidates[0].Key != "alpha" || candidates[1].Key != "middle" || candidates[2].Key != "zeta" { + t.Fatalf("unexpected model candidates: %#v", candidates) + } +} + +func TestRegistryConcurrentPublicationPreservesAllUniquePlugins(t *testing.T) { + registry := NewRegistry() + const count = 12 + var group sync.WaitGroup + errors := make(chan error, count) + for index := 0; index < count; index++ { + index := index + group.Add(1) + go func() { + defer group.Done() + key := fmt.Sprintf("p%02d", index) + if _, err := registry.Publish(registryManifest(key, "/"+key)); err != nil { + errors <- err + } + }() + } + group.Wait() + close(errors) + for err := range errors { + t.Fatalf("concurrent publication failed: %v", err) + } + snapshot := registry.Snapshot() + if snapshot.Generation() != count { + t.Fatalf("expected generation %d, got %d", count, snapshot.Generation()) + } + if len(snapshot.ModelCandidates("vendor-model")) != count { + t.Fatalf("expected %d model candidates", count) + } +} From e92dd464f28c98d845a4458599dceb4699e1421d Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 20:28:06 +0800 Subject: [PATCH 22/23] test(taskplugin): add deterministic mock driver --- pkg/taskplugin/mock.go | 154 ++++++++++++++++++++++++++++ pkg/taskplugin/mock_test.go | 169 +++++++++++++++++++++++++++++++ service/task_plugin_mock_test.go | 59 +++++++++++ 3 files changed, 382 insertions(+) create mode 100644 pkg/taskplugin/mock.go create mode 100644 pkg/taskplugin/mock_test.go create mode 100644 service/task_plugin_mock_test.go diff --git a/pkg/taskplugin/mock.go b/pkg/taskplugin/mock.go new file mode 100644 index 00000000..734ca7e2 --- /dev/null +++ b/pkg/taskplugin/mock.go @@ -0,0 +1,154 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package taskplugin + +import ( + "fmt" + "sync" + + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" + "github.com/MAX-API-Next/MAX-API/types" +) + +const ( + MockTaskStatusInProgress = "in_progress" + MockTaskStatusSuccess = "success" +) + +type MockDriver struct { + mu sync.Mutex + contract types.TaskUsageContract + usage *types.TaskUsage + nextID uint64 + tasks map[string]*mockTask +} + +type MockTaskResult struct { + TaskID string + Status string + UsageEnvelope *types.TaskUsageEnvelope +} + +type mockTask struct { + status string + polls int + usageEnvelope *types.TaskUsageEnvelope +} + +func NewMockDriver(contract types.TaskUsageContract, usage *types.TaskUsage) (*MockDriver, error) { + if _, err := taskusage.ContractDigest(contract); err != nil { + return nil, fmt.Errorf("validate mock task usage contract: %w", err) + } + return &MockDriver{ + contract: cloneUsageContract(contract), + usage: types.CloneTaskUsage(usage), + tasks: make(map[string]*mockTask), + }, nil +} + +func (driver *MockDriver) UsageContract() types.TaskUsageContract { + if driver == nil { + return types.TaskUsageContract{} + } + driver.mu.Lock() + defer driver.mu.Unlock() + return cloneUsageContract(driver.contract) +} + +func (driver *MockDriver) UsageProducerKind() string { + return types.TaskUsageProducerKindTaskPlugin +} + +func (driver *MockDriver) ProduceUsage(context types.TaskUsageContext) (*types.TaskUsageEnvelope, error) { + if driver == nil { + return nil, fmt.Errorf("mock task plugin driver is nil") + } + driver.mu.Lock() + contract := cloneUsageContract(driver.contract) + usage := types.CloneTaskUsage(driver.usage) + driver.mu.Unlock() + if usage != nil { + usage.Source = context.Stage + if usage.Completeness == "" { + usage.Completeness = types.TaskUsageCompletenessComplete + } + } + return taskusage.BuildEnvelope(types.TaskUsageProducerKindTaskPlugin, contract, context.Stage, usage) +} + +func (driver *MockDriver) Submit() (string, error) { + if driver == nil { + return "", fmt.Errorf("mock task plugin driver is nil") + } + driver.mu.Lock() + defer driver.mu.Unlock() + driver.nextID++ + taskID := fmt.Sprintf("mock-task-%d", driver.nextID) + driver.tasks[taskID] = &mockTask{status: MockTaskStatusInProgress} + return taskID, nil +} + +func (driver *MockDriver) Poll(taskID string) (MockTaskResult, error) { + if driver == nil { + return MockTaskResult{}, fmt.Errorf("mock task plugin driver is nil") + } + driver.mu.Lock() + defer driver.mu.Unlock() + task, ok := driver.tasks[taskID] + if !ok { + return MockTaskResult{}, fmt.Errorf("mock task %q not found", taskID) + } + if task.status == MockTaskStatusSuccess { + return MockTaskResult{TaskID: taskID, Status: task.status, UsageEnvelope: types.CloneTaskUsageEnvelope(task.usageEnvelope)}, nil + } + task.polls++ + if task.polls == 1 { + return MockTaskResult{TaskID: taskID, Status: MockTaskStatusInProgress}, nil + } + usage := types.CloneTaskUsage(driver.usage) + if usage != nil { + usage.Source = types.TaskUsageSourceProviderResponse + if usage.Completeness == "" { + usage.Completeness = types.TaskUsageCompletenessComplete + } + } + envelope, err := taskusage.BuildEnvelope(types.TaskUsageProducerKindTaskPlugin, driver.contract, types.TaskUsageSourceProviderResponse, usage) + if err != nil { + return MockTaskResult{}, fmt.Errorf("produce mock task usage: %w", err) + } + task.status = MockTaskStatusSuccess + task.usageEnvelope = types.CloneTaskUsageEnvelope(envelope) + return MockTaskResult{TaskID: taskID, Status: task.status, UsageEnvelope: types.CloneTaskUsageEnvelope(envelope)}, nil +} + +func cloneUsageContract(contract types.TaskUsageContract) types.TaskUsageContract { + contract.Fields = append([]types.TaskUsageFieldContract(nil), contract.Fields...) + for index := range contract.Fields { + if contract.Fields[index].MinValue != nil { + value := *contract.Fields[index].MinValue + contract.Fields[index].MinValue = &value + } + if contract.Fields[index].MaxValue != nil { + value := *contract.Fields[index].MaxValue + contract.Fields[index].MaxValue = &value + } + } + return contract +} diff --git a/pkg/taskplugin/mock_test.go b/pkg/taskplugin/mock_test.go new file mode 100644 index 00000000..fc29ea81 --- /dev/null +++ b/pkg/taskplugin/mock_test.go @@ -0,0 +1,169 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package taskplugin + +import ( + "sync" + "testing" + + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" + "github.com/MAX-API-Next/MAX-API/types" +) + +func TestMockDriverProducesTrustedTaskPluginEnvelope(t *testing.T) { + outputDuration := int64(5_000) + imageCount := int64(0) + driver, err := NewMockDriver(taskusage.MiniMaxH3Contract(), &types.TaskUsage{ + OutputDurationMs: &outputDuration, + InputImageCount: &imageCount, + Completeness: types.TaskUsageCompletenessComplete, + }) + if err != nil { + t.Fatalf("create mock driver: %v", err) + } + taskID, err := driver.Submit() + if err != nil { + t.Fatalf("submit mock task: %v", err) + } + first, err := driver.Poll(taskID) + if err != nil { + t.Fatalf("first mock poll: %v", err) + } + if first.Status != MockTaskStatusInProgress || first.UsageEnvelope != nil { + t.Fatalf("unexpected first poll: %#v", first) + } + terminal, err := driver.Poll(taskID) + if err != nil { + t.Fatalf("terminal mock poll: %v", err) + } + if terminal.Status != MockTaskStatusSuccess || terminal.UsageEnvelope == nil { + t.Fatalf("unexpected terminal poll: %#v", terminal) + } + if err := taskusage.ValidateEnvelope(taskusage.MiniMaxH3Contract(), terminal.UsageEnvelope, types.TaskUsageProducerKindTaskPlugin); err != nil { + t.Fatalf("validate mock envelope: %v", err) + } + if terminal.UsageEnvelope.Presence != types.TaskUsagePresencePresentValid { + t.Fatalf("expected valid usage presence, got %q", terminal.UsageEnvelope.Presence) + } + if terminal.UsageEnvelope.Usage.InputImageCount == nil || *terminal.UsageEnvelope.Usage.InputImageCount != 0 { + t.Fatal("explicit zero image count was not preserved") + } +} + +func TestMockDriverTerminalPollReplayIsStable(t *testing.T) { + outputDuration := int64(4_000) + imageCount := int64(0) + driver, err := NewMockDriver(taskusage.MiniMaxH3Contract(), &types.TaskUsage{ + OutputDurationMs: &outputDuration, + InputImageCount: &imageCount, + Completeness: types.TaskUsageCompletenessComplete, + }) + if err != nil { + t.Fatalf("create mock driver: %v", err) + } + taskID, err := driver.Submit() + if err != nil { + t.Fatalf("submit mock task: %v", err) + } + if _, err := driver.Poll(taskID); err != nil { + t.Fatalf("first mock poll: %v", err) + } + firstTerminal, err := driver.Poll(taskID) + if err != nil { + t.Fatalf("terminal mock poll: %v", err) + } + replay, err := driver.Poll(taskID) + if err != nil { + t.Fatalf("terminal replay: %v", err) + } + if firstTerminal.UsageEnvelope.EvidenceDigest != replay.UsageEnvelope.EvidenceDigest { + t.Fatalf("terminal replay changed evidence digest: %q vs %q", firstTerminal.UsageEnvelope.EvidenceDigest, replay.UsageEnvelope.EvidenceDigest) + } + if firstTerminal.UsageEnvelope.Usage == replay.UsageEnvelope.Usage { + t.Fatal("terminal replay returned mutable usage pointer") + } +} + +func TestMockDriverConcurrentPollsDoNotDuplicateTerminalEvidence(t *testing.T) { + outputDuration := int64(5_000) + imageCount := int64(0) + driver, err := NewMockDriver(taskusage.MiniMaxH3Contract(), &types.TaskUsage{ + OutputDurationMs: &outputDuration, + InputImageCount: &imageCount, + Completeness: types.TaskUsageCompletenessComplete, + }) + if err != nil { + t.Fatalf("create mock driver: %v", err) + } + taskID, err := driver.Submit() + if err != nil { + t.Fatalf("submit mock task: %v", err) + } + var group sync.WaitGroup + results := make(chan MockTaskResult, 8) + errors := make(chan error, 8) + for i := 0; i < 8; i++ { + group.Add(1) + go func() { + defer group.Done() + result, pollErr := driver.Poll(taskID) + if pollErr != nil { + errors <- pollErr + return + } + results <- result + }() + } + group.Wait() + close(results) + close(errors) + for err := range errors { + t.Fatalf("concurrent poll failed: %v", err) + } + terminalDigest := "" + terminalCount := 0 + for result := range results { + if result.Status != MockTaskStatusSuccess { + continue + } + terminalCount++ + if result.UsageEnvelope == nil { + t.Fatal("successful mock poll omitted usage envelope") + } + if terminalDigest == "" { + terminalDigest = result.UsageEnvelope.EvidenceDigest + } else if terminalDigest != result.UsageEnvelope.EvidenceDigest { + t.Fatal("concurrent terminal polls produced different evidence") + } + } + if terminalCount == 0 { + t.Fatal("concurrent polls never reached terminal state") + } +} + +func TestMockDriverRejectsUnknownTask(t *testing.T) { + driver, err := NewMockDriver(taskusage.MiniMaxH3Contract(), nil) + if err != nil { + t.Fatalf("create mock driver: %v", err) + } + if _, err := driver.Poll("missing"); err == nil { + t.Fatal("expected unknown task to be rejected") + } +} diff --git a/service/task_plugin_mock_test.go b/service/task_plugin_mock_test.go new file mode 100644 index 00000000..e3bffd19 --- /dev/null +++ b/service/task_plugin_mock_test.go @@ -0,0 +1,59 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ + +package service + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/pkg/taskplugin" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/require" +) + +func TestTaskPluginMockDriverEnvelopeAtPollingBoundary(t *testing.T) { + outputDuration := int64(5_000) + imageCount := int64(0) + driver, err := taskplugin.NewMockDriver(taskusage.MiniMaxH3Contract(), &types.TaskUsage{ + OutputDurationMs: &outputDuration, + InputImageCount: &imageCount, + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + taskID, err := driver.Submit() + require.NoError(t, err) + _, err = driver.Poll(taskID) + require.NoError(t, err) + terminal, err := driver.Poll(taskID) + require.NoError(t, err) + require.NotNil(t, terminal.UsageEnvelope) + + adaptor := &usageFactPollingResponseAdaptor{ + contract: driver.UsageContract(), + producerKind: driver.UsageProducerKind(), + envelope: terminal.UsageEnvelope, + } + taskResult := &relaycommon.TaskInfo{} + require.NoError(t, applyTaskUsageFacts(adaptor, []byte(`{}`), taskResult)) + require.NotNil(t, taskResult.UsageEnvelope) + require.Equal(t, types.TaskUsageProducerKindTaskPlugin, taskResult.UsageEnvelope.ProducerKind) + require.EqualValues(t, 5_000, *taskResult.Usage.OutputDurationMs) +} From bc072cdd53240689ed8450eb4a4c1f4778797730 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 16 Oct 2026 21:34:58 +0800 Subject: [PATCH 23/23] test(taskplugin): exercise sqlite settlement replay --- model/task_billing_contract_test.go | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/model/task_billing_contract_test.go b/model/task_billing_contract_test.go index 66225a59..e20b04cc 100644 --- a/model/task_billing_contract_test.go +++ b/model/task_billing_contract_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/pkg/taskplugin" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" "github.com/MAX-API-Next/MAX-API/types" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -378,6 +380,58 @@ func runTaskBillingSettlementContracts(t *testing.T, db *gorm.DB) { }) } +func TestMockTaskPluginUsageFlowsThroughSQLiteSettlementReplay(t *testing.T) { + truncateTables(t) + outputDuration := int64(5_000) + imageCount := int64(0) + driver, err := taskplugin.NewMockDriver(taskusage.MiniMaxH3Contract(), &types.TaskUsage{ + OutputDurationMs: &outputDuration, + InputImageCount: &imageCount, + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + taskID, err := driver.Submit() + require.NoError(t, err) + _, err = driver.Poll(taskID) + require.NoError(t, err) + terminal, err := driver.Poll(taskID) + require.NoError(t, err) + require.NotNil(t, terminal.UsageEnvelope) + + fixture := newTaskBillingContractFixture(t, DB, 9901, BillingSettlementSourceWallet, -20) + require.NotNil(t, fixture.task.PrivateData.BillingContext) + fixture.task.PrivateData.BillingContext.TaskUsageEnvelope = terminal.UsageEnvelope + require.NoError(t, DB.Save(&fixture.task).Error) + + applied, replay, err := ApplyBillingSettlementOnce(fixture.input) + require.NoError(t, err) + require.EqualValues(t, -20, applied) + require.False(t, replay) + require.NoError(t, ProcessBillingSettlementEffect(fixture.input.OperationKey)) + + _, replay, err = ApplyBillingSettlementOnce(fixture.input) + require.NoError(t, err) + require.True(t, replay) + require.NoError(t, ProcessBillingSettlementEffect(fixture.input.OperationKey)) + fixture.assertProjection(t, DB, 80, 1) + var user User + var token Token + var task Task + require.NoError(t, DB.First(&user, fixture.user.Id).Error) + require.NoError(t, DB.First(&token, fixture.token.Id).Error) + require.NoError(t, DB.First(&task, fixture.task.ID).Error) + require.EqualValues(t, 920, user.Quota) + require.EqualValues(t, 920, token.RemainQuota) + require.EqualValues(t, 80, token.UsedQuota) + require.EqualValues(t, 80, task.Quota) + + var stored Task + require.NoError(t, DB.First(&stored, fixture.task.ID).Error) + require.NotNil(t, stored.PrivateData.BillingContext) + require.NotNil(t, stored.PrivateData.BillingContext.TaskUsageEnvelope) + require.Equal(t, terminal.UsageEnvelope.EvidenceDigest, stored.PrivateData.BillingContext.TaskUsageEnvelope.EvidenceDigest) +} + // Guard parsing is tested without a live server or any user credentials. func TestTaskBillingExternalDatabaseGuard(t *testing.T) { for _, test := range []struct {