Files
3x-ui/internal/web/controller/client_happ_test.go
NgaiYeanCoi 6a5b4fab6a 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>
2026-09-13 12:44:55 +02:00

150 lines
4.9 KiB
Go

package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
type fakeHappLinkGenerator struct {
calls int
clientID int
host string
result service.HappLinkResult
err error
}
func (f *fakeHappLinkGenerator) Generate(_ context.Context, clientID int, host string) (service.HappLinkResult, error) {
f.calls++
f.clientID = clientID
f.host = host
return f.result, f.err
}
func newHappClientTestRouter(generator service.HappLinkGenerator) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
c.Next()
})
(&ClientController{happGenerator: generator}).initRouter(router.Group("/clients"))
return router
}
func TestGenerateHappLinkForwardsCurrentRequestAndReturnsOnlyLink(t *testing.T) {
fake := &fakeHappLinkGenerator{result: service.HappLinkResult{EncryptedLink: "happ://crypt5/fresh"}}
router := newHappClientTestRouter(fake)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/clients/happLink/42", nil)
req.Host = "panel.example.com:2053"
router.ServeHTTP(rec, req)
if fake.clientID != 42 || fake.host != "panel.example.com:2053" {
t.Fatalf("Generate args = %d, %q", fake.clientID, fake.host)
}
if fake.calls != 1 {
t.Fatalf("Generate calls = %d", fake.calls)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var response struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if !response.Success || response.Msg != "" {
t.Fatalf("response envelope = success:%t msg:%q", response.Success, response.Msg)
}
var link map[string]string
if err := json.Unmarshal(response.Obj, &link); err != nil {
t.Fatalf("unmarshal link result: %v", err)
}
if len(link) != 1 || link["encryptedLink"] != "happ://crypt5/fresh" {
t.Fatalf("response obj = %#v", link)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal response envelope: %v", err)
}
if len(envelope) != 3 || envelope["success"] == nil || envelope["msg"] == nil || envelope["obj"] == nil {
t.Fatalf("response envelope fields = %#v", envelope)
}
}
func TestGenerateHappLinkRejectsInvalidIDWithoutCallingGenerator(t *testing.T) {
fake := &fakeHappLinkGenerator{}
router := newHappClientTestRouter(fake)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/clients/happLink/0", nil))
if fake.calls != 0 || fake.clientID != 0 || fake.host != "" {
t.Fatalf("Generate called %d times with = %d, %q", fake.calls, fake.clientID, fake.host)
}
assertHappFailureWithoutSecret(t, rec, "fake-provider-secret")
}
func TestGenerateHappLinkDoesNotExposeProviderFailure(t *testing.T) {
fake := &fakeHappLinkGenerator{err: errors.New("fake-provider-secret")}
router := newHappClientTestRouter(fake)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/clients/happLink/42", nil))
if fake.calls != 1 {
t.Fatalf("Generate calls = %d", fake.calls)
}
assertHappFailureWithoutSecret(t, rec, "fake-provider-secret")
}
func TestGenerateHappLinkReturnsOnlySafeLengthCode(t *testing.T) {
fake := &fakeHappLinkGenerator{err: fmt.Errorf("%w: private-subscription-url", service.ErrHappSourceTooLong)}
rec := httptest.NewRecorder()
newHappClientTestRouter(fake).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/clients/happLink/42", nil))
assertHappFailureWithoutSecret(t, rec, "private-subscription-url")
var response struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj any `json:"obj"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Success || response.Msg != "happ_source_too_long" || response.Obj != nil {
t.Fatalf("length failure envelope = %s", rec.Body.String())
}
}
func assertHappFailureWithoutSecret(t *testing.T, rec *httptest.ResponseRecorder, secret string) {
t.Helper()
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"success":false`) {
t.Fatalf("failure response = %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), secret) {
t.Fatalf("failure leaked provider secret: %s", rec.Body.String())
}
}