|
@@ -0,0 +1,646 @@
|
|
|
|
|
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
|
|
|
+import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
+import { createRef, forwardRef, useImperativeHandle, useState } from 'react';
|
|
|
|
|
+import { MemoryRouter, useLocation } from 'react-router';
|
|
|
|
|
+
|
|
|
|
|
+import type { HappLinkResult } from '@/generated/types';
|
|
|
|
|
+import type { ClientRecord } from '@/hooks/useClients';
|
|
|
|
|
+import ClientQrModal from '@/pages/clients/ClientQrModal';
|
|
|
|
|
+import { HttpUtil, Msg } from '@/utils';
|
|
|
|
|
+import { renderWithProviders } from './test-utils';
|
|
|
|
|
+
|
|
|
|
|
+vi.mock('@/pages/inbounds/qr', () => ({
|
|
|
|
|
+ QrPanel: ({ value, showQr = true }: { value: string; showQr?: boolean }) => (
|
|
|
|
|
+ <div data-testid="qr-panel-value" data-show-qr={String(showQr)}>
|
|
|
|
|
+ {value}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+}));
|
|
|
|
|
+
|
|
|
|
|
+const STANDARD_LINK = 'https://panel.example/sub/alpha';
|
|
|
|
|
+const HAPP_LINK = 'happ://crypt5/encrypted-alpha';
|
|
|
|
|
+const HAPP_OPTION_LABEL = 'Happ Encrypted Link';
|
|
|
|
|
+const SOURCE_TOO_LONG_HINT =
|
|
|
|
|
+ 'The subscription URL exceeds the panel limit of 8192 UTF-8 bytes. Shorten the subscription URL or use Standard.';
|
|
|
|
|
+const CLIENT: ClientRecord = { id: 42, email: '[email protected]', subId: 'alpha' };
|
|
|
|
|
+const SUB_SETTINGS = {
|
|
|
|
|
+ enable: true,
|
|
|
|
|
+ subURI: 'https://panel.example/sub/',
|
|
|
|
|
+ subJsonURI: '',
|
|
|
|
|
+ subJsonEnable: false,
|
|
|
|
|
+ happLinkEnable: true,
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type TestSubSettings = Omit<typeof SUB_SETTINGS, 'happLinkEnable'> & {
|
|
|
|
|
+ happLinkEnable?: boolean;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+interface SubjectProps {
|
|
|
|
|
+ open: boolean;
|
|
|
|
|
+ client: ClientRecord | null;
|
|
|
|
|
+ subSettings: TestSubSettings;
|
|
|
|
|
+ onOpenChange: (open: boolean) => void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SubjectHandle {
|
|
|
|
|
+ update: (patch: Partial<SubjectProps>) => void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function deferred<T>() {
|
|
|
|
|
+ let resolve!: (value: T) => void;
|
|
|
|
|
+ const promise = new Promise<T>((next) => {
|
|
|
|
|
+ resolve = next;
|
|
|
|
|
+ });
|
|
|
|
|
+ return { promise, resolve };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function success(encryptedLink = HAPP_LINK) {
|
|
|
|
|
+ return new Msg<HappLinkResult>(true, '', { encryptedLink });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const Subject = forwardRef<SubjectHandle, { overrides: Partial<SubjectProps> }>(function Subject(
|
|
|
|
|
+ { overrides },
|
|
|
|
|
+ ref,
|
|
|
|
|
+) {
|
|
|
|
|
+ const [props, setProps] = useState<SubjectProps>({
|
|
|
|
|
+ open: true,
|
|
|
|
|
+ client: CLIENT,
|
|
|
|
|
+ subSettings: SUB_SETTINGS,
|
|
|
|
|
+ onOpenChange: vi.fn(),
|
|
|
|
|
+ ...overrides,
|
|
|
|
|
+ });
|
|
|
|
|
+ useImperativeHandle(
|
|
|
|
|
+ ref,
|
|
|
|
|
+ () => ({
|
|
|
|
|
+ update: (patch) => setProps((current) => ({ ...current, ...patch })),
|
|
|
|
|
+ }),
|
|
|
|
|
+ [],
|
|
|
|
|
+ );
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ClientQrModal
|
|
|
|
|
+ open={props.open}
|
|
|
|
|
+ client={props.client}
|
|
|
|
|
+ inboundsById={{}}
|
|
|
|
|
+ subSettings={props.subSettings}
|
|
|
|
|
+ onOpenChange={props.onOpenChange}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function LocationProbe() {
|
|
|
|
|
+ const location = useLocation();
|
|
|
|
|
+ return (
|
|
|
|
|
+ <output data-testid="location">
|
|
|
|
|
+ {location.pathname}
|
|
|
|
|
+ {location.search}
|
|
|
|
|
+ {location.hash}
|
|
|
|
|
+ </output>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderSubject(overrides: Partial<SubjectProps> = {}) {
|
|
|
|
|
+ const subjectRef = createRef<SubjectHandle>();
|
|
|
|
|
+ const onOpenChange = vi.fn();
|
|
|
|
|
+ const view = renderWithProviders(
|
|
|
|
|
+ <MemoryRouter initialEntries={['/clients']}>
|
|
|
|
|
+ <Subject ref={subjectRef} overrides={{ onOpenChange, ...overrides }} />
|
|
|
|
|
+ <LocationProbe />
|
|
|
|
|
+ </MemoryRouter>,
|
|
|
|
|
+ );
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...view,
|
|
|
|
|
+ onOpenChange,
|
|
|
|
|
+ update(patch: Partial<SubjectProps>) {
|
|
|
|
|
+ act(() => subjectRef.current?.update(patch));
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function selectVariant(name: 'Standard' | 'Happ') {
|
|
|
|
|
+ fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function actionButton(name: 'Retry' | 'Regenerate') {
|
|
|
|
|
+ return screen.getByRole('button', { name: new RegExp(name) }) as HTMLButtonElement;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe('ClientQrModal Happ presentation', () => {
|
|
|
|
|
+ beforeEach(() => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockReset();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('opens on Standard without generating a Happ link', () => {
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
|
|
|
+ true,
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
|
|
|
+ expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('names the Happ option as an encrypted link', () => {
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+
|
|
|
|
|
+ expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['missing', undefined],
|
|
|
|
|
+ ['false', false],
|
|
|
|
|
+ ])('marks the selectable Happ option as locked when the gate is %s', (_name, gate) => {
|
|
|
|
|
+ const subSettings: TestSubSettings = {
|
|
|
|
|
+ enable: SUB_SETTINGS.enable,
|
|
|
|
|
+ subURI: SUB_SETTINGS.subURI,
|
|
|
|
|
+ subJsonURI: SUB_SETTINGS.subJsonURI,
|
|
|
|
|
+ subJsonEnable: SUB_SETTINGS.subJsonEnable,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (gate !== undefined) subSettings.happLinkEnable = gate;
|
|
|
|
|
+
|
|
|
|
|
+ renderSubject({ subSettings });
|
|
|
|
|
+
|
|
|
|
|
+ const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
|
|
|
|
+ const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
|
|
|
|
+ expect(standard.checked).toBe(true);
|
|
|
|
|
+ expect(happ.disabled).toBe(false);
|
|
|
|
|
+ expect(
|
|
|
|
|
+ screen.getByLabelText('Enable Happ link generation in Settings before using Happ.', {
|
|
|
|
|
+ selector: '.anticon-lock',
|
|
|
|
|
+ }),
|
|
|
|
|
+ ).toBeTruthy();
|
|
|
|
|
+ expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['missing', undefined],
|
|
|
|
|
+ ['false', false],
|
|
|
|
|
+ ])(
|
|
|
|
|
+ 'replaces the blank Happ content with a persistent empty state when the gate is %s',
|
|
|
|
|
+ (_name, gate) => {
|
|
|
|
|
+ const subSettings: TestSubSettings = {
|
|
|
|
|
+ enable: SUB_SETTINGS.enable,
|
|
|
|
|
+ subURI: SUB_SETTINGS.subURI,
|
|
|
|
|
+ subJsonURI: SUB_SETTINGS.subJsonURI,
|
|
|
|
|
+ subJsonEnable: SUB_SETTINGS.subJsonEnable,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (gate !== undefined) subSettings.happLinkEnable = gate;
|
|
|
|
|
+
|
|
|
|
|
+ renderSubject({ subSettings });
|
|
|
|
|
+
|
|
|
|
|
+ const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
|
|
|
|
+ const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
|
|
|
|
+ fireEvent.click(happ);
|
|
|
|
|
+
|
|
|
|
|
+ expect(standard.checked).toBe(false);
|
|
|
|
|
+ expect(happ.checked).toBe(true);
|
|
|
|
|
+ expect(screen.getByText('Happ encrypted link generation is not enabled')).toBeTruthy();
|
|
|
|
|
+ expect(
|
|
|
|
|
+ screen.getByText(
|
|
|
|
|
+ 'Enable local generation of encrypted Happ subscription links. (Only for Happ)',
|
|
|
|
|
+ ),
|
|
|
|
|
+ ).toBeTruthy();
|
|
|
|
|
+ expect(screen.getByRole('button', { name: 'Go to Settings' })).toBeTruthy();
|
|
|
|
|
+ expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
|
+ expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
|
|
|
|
|
+ expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ it('removes the hover and focus tooltip from the locked Happ option', async () => {
|
|
|
|
|
+ renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
|
|
|
+ const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ });
|
|
|
|
|
+ const happLabel = happ.closest('label');
|
|
|
|
|
+ expect(happLabel).not.toBeNull();
|
|
|
|
|
+
|
|
|
|
|
+ fireEvent.mouseEnter(happLabel!);
|
|
|
|
|
+ fireEvent.focus(happ);
|
|
|
|
|
+ await act(async () => {
|
|
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 250));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(screen.queryByRole('tooltip')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('closes the QR modal and deep-links to Happ settings without generating', () => {
|
|
|
|
|
+ const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
|
|
|
|
+
|
|
|
|
|
+ expect(view.onOpenChange).toHaveBeenCalledOnce();
|
|
|
|
|
+ expect(view.onOpenChange).toHaveBeenCalledWith(false);
|
|
|
|
|
+ expect(screen.getByTestId('location').textContent).toBe(
|
|
|
|
|
+ '/settings?subscriptionTab=happ&happTab=links#subscription',
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('returns to Standard without auto-generating when the gate is enabled after selecting Happ', async () => {
|
|
|
|
|
+ const view = renderSubject({
|
|
|
|
|
+ subSettings: { ...SUB_SETTINGS, happLinkEnable: false },
|
|
|
|
|
+ });
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).checked).toBe(true);
|
|
|
|
|
+ expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: true } });
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() =>
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
|
|
|
+ true,
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(
|
|
|
|
|
+ screen.queryByText(
|
|
|
|
|
+ 'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
|
|
|
|
|
+ ),
|
|
|
|
|
+ ).toBeNull();
|
|
|
|
|
+ expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('keeps the local encryption notice out of Standard and shows it only in Happ', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockReturnValue(new Promise(() => {}));
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
|
|
|
+ true,
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(
|
|
|
|
|
+ screen.queryByText(
|
|
|
|
|
+ 'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
|
|
|
|
|
+ ),
|
|
|
|
|
+ ).toBeNull();
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ expect(
|
|
|
|
|
+ await screen.findByText(
|
|
|
|
|
+ 'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
|
|
|
|
|
+ ),
|
|
|
|
|
+ ).toBeTruthy();
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('posts once with no body and silent errors when Standard switches to Happ', async () => {
|
|
|
|
|
+ const request = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ const dialogCount = screen.queryAllByRole('dialog').length;
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledWith('/panel/api/clients/happLink/42', undefined, {
|
|
|
|
|
+ silent: true,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(confirmSpy).not.toHaveBeenCalled();
|
|
|
|
|
+ expect(screen.queryAllByRole('dialog')).toHaveLength(dialogCount);
|
|
|
|
|
+ confirmSpy.mockRestore();
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
|
|
|
|
|
+ false,
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('removes the Happ value on leave and makes a fresh request on re-entry', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post)
|
|
|
|
|
+ .mockResolvedValueOnce(success())
|
|
|
|
|
+ .mockResolvedValueOnce(success('happ://crypt5/encrypted-second'));
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Standard');
|
|
|
|
|
+ expect(screen.queryByText(HAPP_LINK)).toBeNull();
|
|
|
|
|
+ expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText('happ://crypt5/encrypted-second')).toBeTruthy();
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledTimes(2);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('keeps request B current when request A resolves after leaving and re-entering Happ', async () => {
|
|
|
|
|
+ const requestA = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ const requestB = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ vi.mocked(HttpUtil.post)
|
|
|
|
|
+ .mockReturnValueOnce(requestA.promise)
|
|
|
|
|
+ .mockReturnValueOnce(requestB.promise);
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
|
|
|
+ selectVariant('Standard');
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
|
|
|
|
+
|
|
|
|
|
+ await act(async () => {
|
|
|
|
|
+ requestA.resolve(success('happ://crypt5/request-a'));
|
|
|
|
|
+ await requestA.promise;
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
|
+
|
|
|
|
|
+ await act(async () => {
|
|
|
|
|
+ requestB.resolve(success('happ://crypt5/request-b'));
|
|
|
|
|
+ await requestB.promise;
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(await screen.findByText('happ://crypt5/request-b')).toBeTruthy();
|
|
|
|
|
+ expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('returns to Standard and ignores an in-flight response when the gate turns off', async () => {
|
|
|
|
|
+ const request = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
|
|
|
|
|
+ const view = renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
|
|
|
+ true,
|
|
|
|
|
+ );
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).disabled).toBe(
|
|
|
|
|
+ false,
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
|
+ expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
|
|
|
|
|
+
|
|
|
|
|
+ await act(async () => {
|
|
|
|
|
+ request.resolve(success('happ://crypt5/retired-by-gate'));
|
|
|
|
|
+ await request.promise;
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(screen.queryByText('happ://crypt5/retired-by-gate')).toBeNull();
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('keeps the dialog mounted through close and shows loading instead of noLinks on reopen', async () => {
|
|
|
|
|
+ const get = vi.mocked(HttpUtil.get);
|
|
|
|
|
+ const previousGet = get.getMockImplementation();
|
|
|
|
|
+ get.mockReturnValue(new Promise(() => {}));
|
|
|
|
|
+ try {
|
|
|
|
|
+ const view = renderSubject({ subSettings: { ...SUB_SETTINGS, enable: false } });
|
|
|
|
|
+ const dialog = screen.getByRole('dialog');
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ open: false });
|
|
|
|
|
+ expect(document.body.contains(dialog)).toBe(true);
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ open: true });
|
|
|
|
|
+ await waitFor(() =>
|
|
|
|
|
+ expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).not.toBeNull(),
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(screen.queryByText(/No shareable links/)).toBeNull();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ get.mockImplementation(previousGet!);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('resets to Standard across close and reopen without reusing a prior Happ value', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(success());
|
|
|
|
|
+ const view = renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ open: false });
|
|
|
|
|
+ view.update({ open: true });
|
|
|
|
|
+
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
|
|
|
+ true,
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
|
|
|
+ expect(screen.queryByText(HAPP_LINK)).toBeNull();
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['leaving Happ', (_view: ReturnType<typeof renderSubject>) => selectVariant('Standard')],
|
|
|
|
|
+ ['closing', (view: ReturnType<typeof renderSubject>) => view.update({ open: false })],
|
|
|
|
|
+ [
|
|
|
|
|
+ 'changing client id',
|
|
|
|
|
+ (view: ReturnType<typeof renderSubject>) => view.update({ client: { ...CLIENT, id: 77 } }),
|
|
|
|
|
+ ],
|
|
|
|
|
+ [
|
|
|
|
|
+ 'changing subId',
|
|
|
|
|
+ (view: ReturnType<typeof renderSubject>) =>
|
|
|
|
|
+ view.update({ client: { ...CLIENT, subId: 'beta' } }),
|
|
|
|
|
+ ],
|
|
|
|
|
+ [
|
|
|
|
|
+ 'changing the effective subscription source',
|
|
|
|
|
+ (view: ReturnType<typeof renderSubject>) =>
|
|
|
|
|
+ view.update({
|
|
|
|
|
+ subSettings: { ...SUB_SETTINGS, subURI: 'https://other.example/sub/' },
|
|
|
|
|
+ }),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ])('ignores a generation response after %s', async (_name, retire) => {
|
|
|
|
|
+ const oldRequest = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ const nextRequest = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ vi.mocked(HttpUtil.post)
|
|
|
|
|
+ .mockReturnValueOnce(oldRequest.promise)
|
|
|
|
|
+ .mockReturnValue(nextRequest.promise);
|
|
|
|
|
+ const view = renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
|
|
|
+
|
|
|
|
|
+ retire(view);
|
|
|
|
|
+ await act(async () => {
|
|
|
|
|
+ oldRequest.resolve(success('happ://crypt5/retired-response'));
|
|
|
|
|
+ await oldRequest.promise;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ expect(screen.queryByText('happ://crypt5/retired-response')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('shows only the localized generic hint and Retry after a backend failure', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(
|
|
|
|
|
+ new Msg<HappLinkResult>(false, 'provider token leaked by backend', null),
|
|
|
|
|
+ );
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ await screen.findByText('Retry');
|
|
|
|
|
+ expect(actionButton('Retry').disabled).toBe(false);
|
|
|
|
|
+ expect(
|
|
|
|
|
+ screen.getByText(
|
|
|
|
|
+ 'The Happ link could not be generated. Retry, or check Overview -> Logs for details.',
|
|
|
|
|
+ ),
|
|
|
|
|
+ ).toBeTruthy();
|
|
|
|
|
+ expect(screen.queryByText(/provider token leaked/i)).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('retries with a fresh request and exposes Regenerate after success', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post)
|
|
|
|
|
+ .mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'backend detail', null))
|
|
|
|
|
+ .mockResolvedValueOnce(success());
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ await screen.findByText('Retry');
|
|
|
|
|
+ fireEvent.click(actionButton('Retry'));
|
|
|
|
|
+
|
|
|
|
|
+ expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
|
+ expect(actionButton('Regenerate').disabled).toBe(false);
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledTimes(2);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('explains a source length failure without Retry and keeps Standard available', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(
|
|
|
|
|
+ new Msg<HappLinkResult>(false, 'happ_source_too_long', null),
|
|
|
|
|
+ );
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
|
+ expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
|
|
|
+ expect(screen.queryByText('happ_source_too_long')).toBeNull();
|
|
|
|
|
+
|
|
|
|
|
+ selectVariant('Standard');
|
|
|
|
|
+ expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
|
|
|
+ expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
|
|
|
+ expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['non-exact error code', false, 'happ_source_too_long token=secret'],
|
|
|
|
|
+ ['successful malformed response', true, 'happ_source_too_long'],
|
|
|
|
|
+ ])('does not trust a %s as a source length failure', async (_name, successful, message) => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(new Msg<HappLinkResult>(successful, message, null));
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ expect(await screen.findByRole('button', { name: 'Retry' })).toBeTruthy();
|
|
|
|
|
+ expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
|
|
|
+ expect(screen.queryByText(message)).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('clears a source length failure when the subscription source changes', async () => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post)
|
|
|
|
|
+ .mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'happ_source_too_long', null))
|
|
|
|
|
+ .mockResolvedValueOnce(success());
|
|
|
|
|
+ const view = renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ subSettings: { ...SUB_SETTINGS, subURI: 'https://short.example/sub/' } });
|
|
|
|
|
+ expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
|
|
|
+ true,
|
|
|
|
|
+ );
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
|
+ expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('ignores a source length failure from a retired request', async () => {
|
|
|
|
|
+ const retired = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockReturnValueOnce(retired.promise).mockResolvedValueOnce(success());
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ selectVariant('Standard');
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ await act(async () => {
|
|
|
|
|
+ retired.resolve(new Msg<HappLinkResult>(false, 'happ_source_too_long', null));
|
|
|
|
|
+ await retired.promise;
|
|
|
|
|
+ });
|
|
|
|
|
+ expect(screen.getByTestId('qr-panel-value').textContent).toBe(HAPP_LINK);
|
|
|
|
|
+ expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['Retry', new Msg<HappLinkResult>(false, 'backend detail', null)],
|
|
|
|
|
+ ['Regenerate', success()],
|
|
|
|
|
+ ])('does not let a stale %s action bypass a disabled gate', async (action, response) => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(response);
|
|
|
|
|
+ const view = renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ const staleAction = await screen.findByRole('button', { name: new RegExp(action) });
|
|
|
|
|
+
|
|
|
|
|
+ view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
|
|
|
+ await waitFor(() =>
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: new RegExp(action) })).toBeNull(),
|
|
|
|
|
+ );
|
|
|
|
|
+ fireEvent.click(staleAction);
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('clears the old QR and hides duplicate regeneration while loading', async () => {
|
|
|
|
|
+ const regeneration = deferred<Msg<HappLinkResult>>();
|
|
|
|
|
+ vi.mocked(HttpUtil.post)
|
|
|
|
|
+ .mockResolvedValueOnce(success())
|
|
|
|
|
+ .mockReturnValueOnce(regeneration.promise);
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+ expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
|
+ await waitFor(() => expect(actionButton('Regenerate').disabled).toBe(false));
|
|
|
|
|
+
|
|
|
|
|
+ fireEvent.click(actionButton('Regenerate'));
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
|
|
|
|
+ expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
|
|
|
+ expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
|
+ expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
|
|
|
|
|
+ false,
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['ordinary', 'happ://crypt5/AaBbCc-._~'],
|
|
|
|
|
+ ['standard Base64', 'happ://crypt5/AaBb+Cc/Dd=='],
|
|
|
|
|
+ ['maximum-size QR', `happ://crypt5/${'a'.repeat(2939)}`],
|
|
|
|
|
+ ])('passes a valid %s encryptedLink unchanged to QrPanel', async (_name, exactLink) => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(success(exactLink));
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ const panel = await screen.findByTestId('qr-panel-value');
|
|
|
|
|
+ expect(panel.textContent).toBe(exactLink);
|
|
|
|
|
+ expect(panel.getAttribute('data-show-qr')).toBe('true');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['ASCII', `happ://crypt5/${'a'.repeat(2940)}`],
|
|
|
|
|
+ ['multi-byte', `happ://crypt5/${'界'.repeat(1000)}`],
|
|
|
|
|
+ ])('keeps a valid %s link available when it is too large for a QR code', async (_name, link) => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(success(link));
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ const panel = await screen.findByTestId('qr-panel-value');
|
|
|
|
|
+ expect(panel.textContent).toBe(link);
|
|
|
|
|
+ expect(panel.getAttribute('data-show-qr')).toBe('false');
|
|
|
|
|
+ expect(
|
|
|
|
|
+ screen.getByText(
|
|
|
|
|
+ 'This Happ link is valid, but it is too long to display as a QR code. Use Copy to use the complete link.',
|
|
|
|
|
+ ),
|
|
|
|
|
+ ).toBeTruthy();
|
|
|
|
|
+ expect(actionButton('Regenerate').disabled).toBe(false);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it.each([
|
|
|
|
|
+ ['non-string', { encryptedLink: 7 }],
|
|
|
|
|
+ ['stale crypt4 format', { encryptedLink: 'happ://crypt4/old-format' }],
|
|
|
|
|
+ ['empty payload', { encryptedLink: 'happ://crypt5/' }],
|
|
|
|
|
+ ['wrong scheme', { encryptedLink: 'https://provider.example/link' }],
|
|
|
|
|
+ ['whitespace', { encryptedLink: 'happ://crypt5/has space' }],
|
|
|
|
|
+ ['control character', { encryptedLink: 'happ://crypt5/example\n' }],
|
|
|
|
|
+ ])('rejects a %s encryptedLink before rendering QrPanel', async (_name, obj) => {
|
|
|
|
|
+ vi.mocked(HttpUtil.post).mockResolvedValue(new Msg(true, '', obj));
|
|
|
|
|
+ renderSubject();
|
|
|
|
|
+ selectVariant('Happ');
|
|
|
|
|
+
|
|
|
|
|
+ await screen.findByText('Retry');
|
|
|
|
|
+ expect(actionButton('Retry').disabled).toBe(false);
|
|
|
|
|
+ expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|