package render import ( "encoding/json" "testing" "webui4frpc/internal/store" ) func TestRenderTCPProxy(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"} data, err := Render(remote, []Proxy{ {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080}, }) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } if cfg.ServerAddr != "1.2.3.4" || cfg.Auth.Token != "secret" { t.Fatalf("cfg = %+v", cfg) } if len(cfg.Proxies) != 1 || cfg.Proxies[0].RemotePort != 8080 { t.Fatalf("proxies = %+v", cfg.Proxies) } } func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} proxy := []Proxy{ {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080}, {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8081}, } data, err := Render(remote, proxy) if err != nil { t.Fatal(err) } var cfg Config _ = json.Unmarshal(data, &cfg) if len(cfg.Proxies) != 2 { t.Fatalf("proxies = %+v", cfg.Proxies) } if cfg.Proxies[1].Name != "web-8081" { t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name) } } func TestRenderTransportAdvanced(t *testing.T) { remote := store.Remote{ Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret", TransportProtocol: "kcp", TransportTLS: true, TransportPool: 3, TransportTLSServerName: "frps.local", } data, err := Render(remote, []Proxy{ {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080, UseEncryption: true, UseCompression: true, BandwidthLimit: "1MB", PoolCount: 2, Metadatas: map[string]string{"env": "prod"}, Annotations: map[string]string{"owner": "ops"}}, }) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } if cfg.Transport.Protocol != "kcp" { t.Fatalf("transport.protocol = %q, want kcp", cfg.Transport.Protocol) } if cfg.Transport.TLS == nil || !cfg.Transport.TLS.Enable || cfg.Transport.TLS.ServerName != "frps.local" { t.Fatalf("transport.tls = %+v", cfg.Transport.TLS) } if cfg.Transport.PoolCount != 3 { t.Fatalf("transport.poolCount = %d, want 3", cfg.Transport.PoolCount) } p := cfg.Proxies[0] // useEncryption/useCompression/bandwidthLimit belong to the proxy's nested // `transport` object. Asserting them on the proxy top level is what let the // old renderer ship a config frpc rejects outright (`unknown field // "useEncryption"` under --strict-config). if p.Transport == nil { t.Fatalf("proxy transport section missing: %+v", p) } if !p.Transport.UseEncryption || !p.Transport.UseCompression || p.Transport.BandwidthLimit != "1MB" { t.Fatalf("proxy transport = %+v", p.Transport) } if p.Metadatas["env"] != "prod" || p.Annotations["owner"] != "ops" { t.Fatalf("proxy metadatas/annotations = %+v", p) } // Regression guard: the top level must carry no encryption/pool keys, since // frpc runs with --strict-config and rejects unknown fields. var raw struct { Proxies []map[string]any `json:"proxies"` } if err := json.Unmarshal(data, &raw); err != nil { t.Fatal(err) } for _, bad := range []string{"useEncryption", "useCompression", "poolCount"} { if _, ok := raw.Proxies[0][bad]; ok { t.Fatalf("proxy top level must not carry %q: %s", bad, data) } } } func TestRenderTransportOmittedWhenEmpty(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} data, err := Render(remote, []Proxy{ {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080}, }) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } if cfg.Transport.Protocol != "" || cfg.Transport.TLS != nil || cfg.Transport.PoolCount != 0 { t.Fatalf("transport should be empty, got %+v", cfg.Transport) } if cfg.Proxies[0].Transport != nil { t.Fatalf("proxy transport should be empty when no proxy-level knobs are set, got %+v", cfg.Proxies[0].Transport) } } func TestRenderHTTPAdvanced(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"} data, err := Render(remote, []Proxy{ {Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080, CustomDomains: []string{"app.example.com"}, Locations: []string{"/api", "/admin"}, HostHeaderRewrite: "backend.internal", HTTPHeaders: map[string]string{"X-Custom": "val"}, BasicAuthUser: "user", BasicAuthPassword: "pass"}, }) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } p := cfg.Proxies[0] if p.CustomDomains[0] != "app.example.com" { t.Fatalf("customDomains = %+v", p.CustomDomains) } if len(p.Locations) != 2 || p.Locations[0] != "/api" { t.Fatalf("locations = %+v", p.Locations) } if p.HostHeaderRewrite != "backend.internal" { t.Fatalf("hostHeaderRewrite = %q", p.HostHeaderRewrite) } if p.HTTPHeaders["X-Custom"] != "val" { t.Fatalf("httpHeaders = %+v", p.HTTPHeaders) } if p.HTTPBasicAuth == nil || p.HTTPBasicAuth.User != "user" || p.HTTPBasicAuth.Password != "pass" { t.Fatalf("basicAuth = %+v", p.HTTPBasicAuth) } } func TestRenderHTTPSUsesCustomDomains(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} data, err := Render(remote, []Proxy{ {Name: "secure", Type: "https", LocalIP: "127.0.0.1", LocalPort: 8443, CustomDomains: []string{"secure.example.com"}}, }) if err != nil { t.Fatal(err) } var cfg Config _ = json.Unmarshal(data, &cfg) p := cfg.Proxies[0] if p.RemotePort != 0 { t.Fatalf("https proxy should not set remotePort, got %d", p.RemotePort) } if len(p.CustomDomains) != 1 || p.CustomDomains[0] != "secure.example.com" { t.Fatalf("customDomains = %+v", p.CustomDomains) } } func TestRenderLoadBalancerAndHealthCheck(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"} data, err := Render(remote, []Proxy{ {Name: "web1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080, LBGroup: "web", LBGroupKey: "k", HealthCheckType: "tcp", HealthCheckTimeout: 3, HealthCheckMaxFailed: 2, HealthCheckInterval: 5}, }) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } p := cfg.Proxies[0] if p.LoadBalancer == nil || p.LoadBalancer.Group != "web" || p.LoadBalancer.GroupKey != "k" { t.Fatalf("loadBalancer = %+v", p.LoadBalancer) } if p.HealthCheck == nil || p.HealthCheck.Type != "tcp" || p.HealthCheck.IntervalSeconds != 5 || p.HealthCheck.MaxFailed != 2 || p.HealthCheck.TimeoutSeconds != 3 { t.Fatalf("healthCheck = %+v", p.HealthCheck) } } func TestRenderHealthCheckHTTPWithHeaders(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} data, err := Render(remote, []Proxy{ {Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080, CustomDomains: []string{"app.example.com"}, HealthCheckType: "http", HealthCheckPath: "/healthz", HealthCheckTimeout: 2, HealthCheckMaxFailed: 3, HealthCheckInterval: 7, HTTPHeaders: map[string]string{"X-Probe": "1"}}, }) if err != nil { t.Fatal(err) } var cfg Config _ = json.Unmarshal(data, &cfg) p := cfg.Proxies[0] if p.HealthCheck == nil || p.HealthCheck.Type != "http" || p.HealthCheck.Path != "/healthz" || p.HealthCheck.IntervalSeconds != 7 { t.Fatalf("healthCheck = %+v", p.HealthCheck) } if p.HealthCheck.HTTPHeaders == nil || len(p.HealthCheck.HTTPHeaders) == 0 || p.HealthCheck.HTTPHeaders[0].Name != "X-Probe" || p.HealthCheck.HTTPHeaders[0].Value != "1" { t.Fatalf("healthCheck.httpHeaders = %+v", p.HealthCheck.HTTPHeaders) } // http proxy must still use customDomains and no remotePort if len(p.CustomDomains) != 1 || p.CustomDomains[0] != "app.example.com" || p.RemotePort != 0 { t.Fatalf("http proxy routing = customDomains=%v remotePort=%d", p.CustomDomains, p.RemotePort) } } func TestRenderHealthCheckOmittedWhenOff(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} data, err := Render(remote, []Proxy{ {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080}, }) if err != nil { t.Fatal(err) } var cfg Config _ = json.Unmarshal(data, &cfg) p := cfg.Proxies[0] if p.LoadBalancer != nil || p.HealthCheck != nil { t.Fatalf("lb/health should be omitted, got lb=%+v hc=%+v", p.LoadBalancer, p.HealthCheck) } } func TestRenderAdminWebServer(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret", AdminAddr: "127.0.0.1", AdminPort: 7400, AdminUser: "admin", AdminPassword: "pw"} data, err := Render(remote, nil) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } if cfg.WebServer == nil { t.Fatal("webServer missing when AdminPort set") } if cfg.WebServer.Port != 7400 || cfg.WebServer.Addr != "127.0.0.1" || cfg.WebServer.User != "admin" || cfg.WebServer.Password != "pw" { t.Fatalf("webServer = %+v", cfg.WebServer) } } func TestRenderAdminWebServerOmitted(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} data, err := Render(remote, nil) if err != nil { t.Fatal(err) } var cfg Config _ = json.Unmarshal(data, &cfg) if cfg.WebServer != nil { t.Fatalf("webServer should be omitted, got %+v", cfg.WebServer) } } // A panel-managed frps refuses logins without `user`, so the rendered config // must carry it verbatim when set. func TestRenderEmitsUserWhenSet(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "tok", User: "e8f52adfaf0e5c95"} data, err := Render(remote, []Proxy{ {Name: "club", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 80, RemotePort: 62962}, }) if err != nil { t.Fatal(err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } if cfg.User != "e8f52adfaf0e5c95" { t.Fatalf("user = %q, want e8f52adfaf0e5c95", cfg.User) } // The key must be present on the wire, not merely in the parsed struct: // an omitempty regression would silently break panel-type frps again. var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { t.Fatal(err) } if _, ok := raw["user"]; !ok { t.Fatalf("rendered JSON has no user key: %s", data) } } // Plain frps rejects unknown fields, so an empty user must be omitted entirely. func TestRenderOmitsUserWhenEmpty(t *testing.T) { remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} data, err := Render(remote, nil) if err != nil { t.Fatal(err) } var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { t.Fatal(err) } if _, ok := raw["user"]; ok { t.Fatalf("empty user must be omitted, got: %s", data) } }