feat(happ): generate Crypt5 subscription links locally (#6494)

* feat(clients): add stateless Happ link generator

Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.

* fix(clients): reject duplicate Happ provider fields

Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.

* feat(clients): expose on-demand Happ link API

Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.

* fix(openapi): exclude service interfaces from generated types

Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.

* feat(clients): add stateless Happ QR presentation

Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.

* fix(clients): cover overlapping Happ generations

Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.

* fix(clients): harden Happ link handling

Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.

* fix(clients): gate Happ link generation behind operator opt-in

- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions

* fix(frontend): guard oversized Happ QR codes

Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.

* fix(clients): log the sanitized transport error for Happ link failures

Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.

Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.

* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close

HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.

Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.

* chore(clients): request Happ crypt5 links from api-v3

crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.

* feat: add local generation of encrypted Happ links

- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.

* fix(frontend): match the tuic memo deps to the non-optional subSettings

The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.

* chore(happ): trim the pinned-key provenance comment to two lines

CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
NgaiYeanCoi
2026-09-13 18:44:55 +08:00
committed by GitHub
parent c3b08b6d9f
commit 6a5b4fab6a
45 changed files with 2652 additions and 26 deletions
@@ -0,0 +1,65 @@
import { fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } 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';
const CLIENT: ClientRecord = { id: 42, email: 'alice@example.com', subId: 'alpha' };
const SUB_SETTINGS = {
enable: true,
subURI: 'https://panel.example/sub/',
subJsonURI: '',
subJsonEnable: false,
happLinkEnable: true,
};
// QrPanel encodes at error level L; QR version 40 holds 2953 bytes at that level.
const LEVEL_L_CAPACITY_BYTES = 2953;
function happLinkOfBytes(bytes: number) {
const prefix = 'happ://crypt5/';
return prefix + 'a'.repeat(bytes - prefix.length);
}
function renderHappVariant(link: string) {
vi.mocked(HttpUtil.post).mockResolvedValue(
new Msg<HappLinkResult>(true, '', { encryptedLink: link }),
);
renderWithProviders(
<MemoryRouter initialEntries={['/clients']}>
<ClientQrModal
open
client={CLIENT}
inboundsById={{}}
subSettings={SUB_SETTINGS}
onOpenChange={() => {}}
/>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('radio', { name: /Happ Encrypted Link/ }));
}
describe('ClientQrModal Happ QR capacity against the real encoder', () => {
beforeEach(() => {
vi.mocked(HttpUtil.post).mockReset();
});
it('renders the QR for a link exactly at the level-L capacity', async () => {
renderHappVariant(happLinkOfBytes(LEVEL_L_CAPACITY_BYTES));
await screen.findByRole('button', { name: 'Regenerate' });
expect(document.body.querySelector('.qr-panel-canvas svg')).not.toBeNull();
expect(screen.queryByText(/too long to display as a QR code/)).toBeNull();
});
it('keeps a link one byte over the capacity available without a QR', async () => {
renderHappVariant(happLinkOfBytes(LEVEL_L_CAPACITY_BYTES + 1));
await screen.findByRole('button', { name: 'Regenerate' });
expect(document.body.querySelector('.qr-panel-canvas')).toBeNull();
expect(screen.getByText(/too long to display as a QR code/)).toBeTruthy();
});
});
+646
View File
@@ -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: 'alice@example.com', 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();
});
});
@@ -58,6 +58,18 @@ function wrapperFor() {
}
describe('useClients query gating', () => {
it.each([
['missing', {}, false],
['false', { happLinkEnable: false }, false],
['true', { happLinkEnable: true }, true],
])('maps a %s Happ gate to a fail-closed client setting', async (_name, defaults, want) => {
mockPanel(defaults);
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
expect(result.current.subSettings.happLinkEnable).toBe(want);
});
it('does not fetch the list until the page supplies a query', async () => {
const pagedUrls = mockPanel({ pageSize: 25 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import ClientInfoModal from '@/pages/clients/ClientInfoModal';
import ClientQrModal from '@/pages/clients/ClientQrModal';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
@@ -168,13 +168,15 @@ describe('Multi-tunnel Client Modals', () => {
it('renders separate collapse panels in ClientQrModal for multiple AmneziaWG inbounds', () => {
renderWithProviders(
<ClientQrModal
open
client={multiAwgClient}
inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
onOpenChange={() => {}}
/>,
<MemoryRouter initialEntries={['/clients']}>
<ClientQrModal
open
client={multiAwgClient}
inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
onOpenChange={() => {}}
/>
</MemoryRouter>,
);
expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
@@ -11,6 +11,7 @@ function LocationProbe() {
return (
<output data-testid="location">
{location.pathname}
{location.search}
{location.hash}
</output>
);
@@ -68,4 +69,81 @@ describe('SubscriptionGeneralTab', () => {
expect(screen.getByTestId('location').textContent).toBe('/settings#subscription-formats');
});
it.each([false, true])(
'updates the Happ link gate from its own tab when stored as %s',
(enabled) => {
const updateSetting = vi.fn();
renderWithProviders(
<MemoryRouter initialEntries={['/settings#subscription']}>
<SubscriptionGeneralTab
allSetting={new AllSetting({ happLinkEnable: enabled })}
updateSetting={updateSetting}
/>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('tab', { name: /Happ/ }));
expect(
screen.getByRole('tab', { name: /Routing & Rules/ }).getAttribute('aria-selected'),
).toBe('true');
expect(screen.queryByRole('switch', { name: 'Encrypted subscription links' })).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: /Subscription Links/ }));
const linkSwitch = screen.getByRole('switch', { name: 'Encrypted subscription links' });
expect(linkSwitch.getAttribute('aria-checked')).toBe(String(enabled));
expect(updateSetting).not.toHaveBeenCalled();
fireEvent.click(linkSwitch);
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ happLinkEnable: !enabled });
},
);
it('opens the Happ link tab from the QR settings deep link without enabling generation', () => {
const updateSetting = vi.fn();
renderWithProviders(
<MemoryRouter initialEntries={['/settings?subscriptionTab=happ&happTab=links#subscription']}>
<SubscriptionGeneralTab
allSetting={new AllSetting({ happLinkEnable: false })}
updateSetting={updateSetting}
/>
<LocationProbe />
</MemoryRouter>,
);
expect(screen.getByRole('tab', { name: /Happ/ }).getAttribute('aria-selected')).toBe('true');
expect(
screen.getByRole('tab', { name: /Subscription Links/ }).getAttribute('aria-selected'),
).toBe('true');
expect(
screen
.getByRole('switch', { name: 'Encrypted subscription links' })
.getAttribute('aria-checked'),
).toBe('false');
expect(screen.getByTestId('location').textContent).toBe(
'/settings?subscriptionTab=happ&happTab=links#subscription',
);
expect(updateSetting).not.toHaveBeenCalled();
});
it.each(['', '&happTab=unknown'])(
'keeps the routing default for a general Happ deep link %s',
(query) => {
const updateSetting = vi.fn();
renderWithProviders(
<MemoryRouter initialEntries={['/settings?subscriptionTab=happ' + query + '#subscription']}>
<SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
</MemoryRouter>,
);
expect(screen.getByRole('tab', { name: /Happ/ }).getAttribute('aria-selected')).toBe('true');
expect(
screen.getByRole('tab', { name: /Routing & Rules/ }).getAttribute('aria-selected'),
).toBe('true');
expect(screen.queryByRole('switch', { name: 'Encrypted subscription links' })).toBeNull();
expect(updateSetting).not.toHaveBeenCalled();
},
);
});