diff --git a/web/__test__/components/CallbackFeedback.test.ts b/web/__test__/components/CallbackFeedback.test.ts index abdb0e8a64..c8a4977fed 100644 --- a/web/__test__/components/CallbackFeedback.test.ts +++ b/web/__test__/components/CallbackFeedback.test.ts @@ -294,18 +294,22 @@ describe('CallbackFeedback.vue', () => { expect(wrapper.text()).toContain('Fix Error'); }); - it('renders the generic error state for failed key installs', () => { + it('renders manual-install recovery for failed key installs', () => { callbackStatus.value = 'error'; - keyActionType.value = 'purchase'; + keyActionType.value = 'trialStart'; keyInstallStatus.value = 'failed'; - keyType.value = 'Pro'; - keyUrl.value = 'https://example.com/pro.key'; + keyType.value = 'Trial'; + keyUrl.value = 'https://keys.lime-technology.com/Trial.key'; const wrapper = mountComponent(); expect(wrapper.find('h1').text()).toBe('Error'); expect(wrapper.find('.description').text()).toBe('Something went wrong'); - expect(wrapper.text()).toContain('Failed to Install Pro Key'); + expect(wrapper.text()).toContain('Failed to Install Trial Key'); + expect(wrapper.get('.brand-button').text()).toBe('Copy Key URL'); + expect(wrapper.get('a[href="/Tools/Registration"]').text()).toBe( + 'Then go to Tools > Registration to manually install it' + ); expect(wrapper.find('.modal').attributes('data-error')).toBe('true'); expect(wrapper.find('.modal').attributes('data-success')).toBe('false'); }); diff --git a/web/__test__/store/installKey.test.ts b/web/__test__/store/installKey.test.ts index 0eb000e742..41559416e0 100644 --- a/web/__test__/store/installKey.test.ts +++ b/web/__test__/store/installKey.test.ts @@ -10,7 +10,13 @@ import type { ExternalKeyActions } from '@unraid/shared-callbacks'; import { useInstallKeyStore } from '~/store/installKey'; -const mockGetFn = vi.fn(); +const mockJsonFn = vi.fn(); +const mockResponseChain = { + error: vi.fn(), + json: mockJsonFn, +}; +mockResponseChain.error.mockReturnValue(mockResponseChain); +const mockGetFn = vi.fn(() => mockResponseChain); vi.mock('~/composables/services/webgui', () => ({ WebguiInstallKey: { query: vi.fn(() => ({ @@ -73,7 +79,7 @@ describe('InstallKey Store', () => { }); it('should set status to installing when install is called', async () => { - mockGetFn.mockResolvedValueOnce({ success: true }); + mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' }); const promise = store.install( createTestAction({ @@ -88,7 +94,7 @@ describe('InstallKey Store', () => { }); it('should handle successful install and update state', async () => { - mockGetFn.mockResolvedValueOnce({ success: true }); + mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' }); const action = createTestAction({ type: 'purchase', keyUrl: 'https://example.com/license.key', @@ -100,6 +106,7 @@ describe('InstallKey Store', () => { expect(WebguiInstallKey.query).toHaveBeenCalledWith({ url: action.keyUrl }); expect(mockGetFn).toHaveBeenCalled(); + expect(mockJsonFn).toHaveBeenCalled(); expect(store.keyInstallStatus).toBe('success'); expect(store.keyActionType).toBe('purchase'); expect(store.keyUrl).toBe('https://example.com/license.key'); @@ -107,7 +114,7 @@ describe('InstallKey Store', () => { }); it('should install trialStart keys using the same install flow', async () => { - mockGetFn.mockResolvedValueOnce({ success: true }); + mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' }); const action = createTestAction({ type: 'trialStart', keyUrl: 'https://example.com/trial.key', @@ -119,6 +126,7 @@ describe('InstallKey Store', () => { expect(WebguiInstallKey.query).toHaveBeenCalledWith({ url: action.keyUrl }); expect(mockGetFn).toHaveBeenCalled(); + expect(mockJsonFn).toHaveBeenCalled(); expect(store.keyInstallStatus).toBe('success'); expect(store.keyActionType).toBe('trialStart'); expect(store.keyUrl).toBe('https://example.com/trial.key'); @@ -126,7 +134,7 @@ describe('InstallKey Store', () => { }); it('should extract key type from .key URL', async () => { - mockGetFn.mockResolvedValueOnce({ success: true }); + mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' }); await store.install( createTestAction({ @@ -139,7 +147,7 @@ describe('InstallKey Store', () => { }); it('should extract key type from .unkey URL', async () => { - mockGetFn.mockResolvedValueOnce({ success: true }); + mockJsonFn.mockResolvedValueOnce({ status: 'success', message: 'Key installed' }); await store.install( createTestAction({ @@ -154,7 +162,7 @@ describe('InstallKey Store', () => { describe('Error Handling', () => { it('should handle string errors during installation', async () => { - mockGetFn.mockRejectedValueOnce('error message'); + mockJsonFn.mockRejectedValueOnce('error message'); await store.install( createTestAction({ @@ -174,7 +182,7 @@ describe('InstallKey Store', () => { }); it('should handle Error object during installation', async () => { - mockGetFn.mockRejectedValueOnce(new Error('Test error message')); + mockJsonFn.mockRejectedValueOnce(new Error('Test error message')); await store.install( createTestAction({ @@ -194,7 +202,7 @@ describe('InstallKey Store', () => { }); it('should handle unknown error types during installation', async () => { - mockGetFn.mockRejectedValueOnce({ something: 'wrong' }); + mockJsonFn.mockRejectedValueOnce({ something: 'wrong' }); await store.install( createTestAction({ @@ -212,5 +220,25 @@ describe('InstallKey Store', () => { type: 'installKey', }); }); + + it('should reject an invalid success response', async () => { + mockJsonFn.mockResolvedValueOnce({ success: true }); + + await store.install( + createTestAction({ + type: 'purchase', + keyUrl: 'https://example.com/license.key', + }) + ); + + expect(store.keyInstallStatus).toBe('failed'); + expect(mockSetError).toHaveBeenCalledWith({ + heading: 'Failed to install key', + message: 'Invalid response from InstallKey.php', + level: 'error', + ref: 'installKey', + type: 'installKey', + }); + }); }); }); diff --git a/web/__test__/store/installKey.wretch.test.ts b/web/__test__/store/installKey.wretch.test.ts new file mode 100644 index 0000000000..6f14feedb6 --- /dev/null +++ b/web/__test__/store/installKey.wretch.test.ts @@ -0,0 +1,129 @@ +import { createPinia, setActivePinia } from 'pinia'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalKeyActions } from '@unraid/shared-callbacks'; + +import { resolveCallbackStatus } from '~/store/callbackActions.helpers'; +import { useErrorsStore } from '~/store/errors'; +import { useInstallKeyStore } from '~/store/installKey'; + +const action: ExternalKeyActions = { + type: 'trialStart', + keyUrl: 'https://keys.lime-technology.com/Trial.key', +}; + +const jsonResponse = (body: unknown, status: number) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const createFetchController = () => { + let resolveFetch: (response: Response) => void = () => { + throw new Error('Fetch controller resolved before initialization'); + }; + let rejectFetch: (error: unknown) => void = () => { + throw new Error('Fetch controller rejected before initialization'); + }; + const response = new Promise((resolve, reject) => { + resolveFetch = resolve; + rejectFetch = reject; + }); + + return { + reject: rejectFetch, + resolve: resolveFetch, + response, + }; +}; + +const flushUnhandledRejections = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('InstallKey Store with real Wretch response chains', () => { + const unhandledRejection = vi.fn(); + + beforeEach(() => { + setActivePinia(createPinia()); + globalThis.csrf_token = ''; + process.on('unhandledRejection', unhandledRejection); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.off('unhandledRejection', unhandledRejection); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + unhandledRejection.mockClear(); + }); + + const startInstall = () => { + const fetchController = createFetchController(); + vi.stubGlobal( + 'fetch', + vi.fn(() => fetchController.response) + ); + const store = useInstallKeyStore(); + const installPromise = store.install(action); + + return { fetchController, installPromise, store }; + }; + + it('stays installing until a delayed successful response is consumed', async () => { + const { fetchController, installPromise, store } = startInstall(); + + expect(store.keyInstallStatus).toBe('installing'); + await Promise.resolve(); + expect(store.keyInstallStatus).toBe('installing'); + + fetchController.resolve(jsonResponse({ status: 'success', message: 'Key installed' }, 200)); + await installPromise; + await flushUnhandledRejections(); + + expect(store.keyInstallStatus).toBe('success'); + expect(unhandledRejection).not.toHaveBeenCalled(); + }); + + it('turns a delayed HTTP 406 into callback error and preserves its safe message', async () => { + const { fetchController, installPromise, store } = startInstall(); + + expect(store.keyInstallStatus).toBe('installing'); + fetchController.resolve(jsonResponse({ error: 'download error 8' }, 406)); + await installPromise; + await flushUnhandledRejections(); + + expect(store.keyInstallStatus).toBe('failed'); + expect(store.keyUrl).toBe(action.keyUrl); + expect(useErrorsStore().errors.at(-1)?.message).toBe('download error 8'); + expect( + resolveCallbackStatus({ + actions: [action], + accountActionStatus: 'ready', + keyInstallStatus: store.keyInstallStatus, + }) + ).toBe('error'); + expect(unhandledRejection).not.toHaveBeenCalled(); + }); + + it('turns a delayed network rejection into callback error', async () => { + const { fetchController, installPromise, store } = startInstall(); + + expect(store.keyInstallStatus).toBe('installing'); + fetchController.reject(new TypeError('Failed to fetch')); + await installPromise; + await flushUnhandledRejections(); + + expect(store.keyInstallStatus).toBe('failed'); + expect(store.keyUrl).toBe(action.keyUrl); + expect(useErrorsStore().errors.at(-1)?.message).toBe('Failed to fetch'); + expect( + resolveCallbackStatus({ + actions: [action], + accountActionStatus: 'ready', + keyInstallStatus: store.keyInstallStatus, + }) + ).toBe('error'); + expect(unhandledRejection).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/store/installKey.ts b/web/src/store/installKey.ts index f394a38a33..4b1bff708a 100644 --- a/web/src/store/installKey.ts +++ b/web/src/store/installKey.ts @@ -6,6 +6,58 @@ import type { ExternalKeyActions } from '@unraid/shared-callbacks'; import { WebguiInstallKey } from '~/composables/services/webgui'; import { useErrorsStore } from '~/store/errors'; +interface InstallKeySuccessResponse { + message: string; + status: 'success'; +} + +interface InstallKeyErrorResponse { + error: string; +} + +const isInstallKeySuccessResponse = (response: unknown): response is InstallKeySuccessResponse => + typeof response === 'object' && + response !== null && + 'message' in response && + typeof response.message === 'string' && + 'status' in response && + response.status === 'success'; + +const isInstallKeyErrorResponse = (response: unknown): response is InstallKeyErrorResponse => + typeof response === 'object' && + response !== null && + 'error' in response && + typeof response.error === 'string'; + +const parseInstallKeyErrorMessage = (message: string): string | undefined => { + try { + const parsed: unknown = JSON.parse(message); + return isInstallKeyErrorResponse(parsed) ? parsed.error : undefined; + } catch { + return undefined; + } +}; + +const getInstallKeyErrorMessage = (error: unknown): string => { + if (typeof error === 'object' && error !== null && 'json' in error) { + const errorJson = error.json; + if (isInstallKeyErrorResponse(errorJson)) { + return errorJson.error; + } + } + if (typeof error === 'string') { + return parseInstallKeyErrorMessage(error) ?? error.toUpperCase(); + } + if (error instanceof Error) { + return parseInstallKeyErrorMessage(error.message) ?? error.message; + } + return 'Unknown error'; +}; + +const rethrowInstallRequestError = (error: unknown): never => { + throw error; +}; + export const useInstallKeyStore = defineStore('installKey', () => { const errorsStore = useErrorsStore(); @@ -36,22 +88,27 @@ export const useInstallKeyStore = defineStore('installKey', () => { } try { - const installResponse = await WebguiInstallKey.query({ url: keyUrl.value }).get(); + const installResponse: unknown = await WebguiInstallKey.query({ url: keyUrl.value }) + .get() + .error('Error', rethrowInstallRequestError) + .error('TypeError', rethrowInstallRequestError) + .json(); console.log('[install] WebguiInstallKey installResponse', installResponse); + if (!isInstallKeySuccessResponse(installResponse)) { + const errorMessage = isInstallKeyErrorResponse(installResponse) + ? installResponse.error + : 'Invalid response from InstallKey.php'; + throw new Error(errorMessage); + } + keyInstallStatus.value = 'success'; } catch (error) { console.error('[install] WebguiInstallKey error', error); - let errorMessage = 'Unknown error'; - if (typeof error === 'string') { - errorMessage = error.toUpperCase(); - } else if (error instanceof Error) { - errorMessage = error.message; - } keyInstallStatus.value = 'failed'; errorsStore.setError({ heading: 'Failed to install key', - message: errorMessage, + message: getInstallKeyErrorMessage(error), level: 'error', ref: 'installKey', type: 'installKey',