Files
fun/hardening_test.go
T
chiyi 41df889a1a v2.0.0: error-channel APIs, correctness fixes, and server hardening
BREAKING CHANGES:
- Guard interface: Guard(ctx Ctx) error; returning error short-circuits
  (subsequent guards and the method no longer execute)
- Wired[T]() (*T, error); New() may return error; failed wiring is sticky
- BindService/BindGuard/BindRoute return error; routes accept guards
  (guards receive merged query/form params as Ctx.State)
- registration panics after Start; Ctx.Ip honors X-Forwarded-For/X-Real-IP
- internal errors sanitized to fixed client messages

FIXES:
- int64 precision loss: /cell data decoded from raw JSON bytes and
  responses serialized with json.Number (no float64 round-trip)
- enum values range-checked before uint8 conversion (256 no longer
  truncates to 0 and slips through)
- logger: files failing name parsing are no longer deleted; log channel
  never blocks request goroutines; ConfigLogger is race-free;
  logWriterWorker unlock bug fixed
- stream writer panics recovered (process no longer crashes);
  streamDone closed exactly once
- generated Go client emits definitions for pointer-to-enum/struct fields
- anonymous structs rejected at registration; isPrivate safe on empty names
- removed dead Result.Id and RequestInfo.Type

ADDITIONS:
- graceful shutdown (Shutdown), StartOn, server timeouts by default
  (Read 60s/Idle 120s/Write off), SetTimeouts/SetMaxConcurrency
- big-int-safe JSON in the generated TS client (>2^53 as BigInt)
- example/ demo services and cmd/genexample artifact generator
2026-09-03 15:32:07 +08:00

344 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fun
// 硬化批次回归测试:真实 IP、路由 Guard、started 护栏与优雅停机、
// 内部错误脱敏、流式 writer panic 兜底、匿名 struct 拒绝、TS 大整数往返
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/valyala/fasthttp"
)
// ---- 真实 IPX-Forwarded-For > X-Real-IP > RemoteAddr ----
func TestClientIP(t *testing.T) {
mk := func(headers map[string]string, remote string) *fasthttp.RequestCtx {
fc := &fasthttp.RequestCtx{}
for k, v := range headers {
fc.Request.Header.Set(k, v)
}
if remote != "" {
fc.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP(remote), Port: 1234})
}
return fc
}
if got := clientIP(mk(map[string]string{"X-Forwarded-For": "1.1.1.1, 2.2.2.2"}, "")); got != "2.2.2.2" {
t.Fatalf("XFF last segment: got %q", got)
}
if got := clientIP(mk(map[string]string{"X-Real-IP": "3.3.3.3"}, "")); got != "3.3.3.3" {
t.Fatalf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(nil, "9.9.9.9")); got != "9.9.9.9" {
t.Fatalf("RemoteAddr fallback: got %q", got)
}
if got := clientIP(mk(nil, "")); got != "127.0.0.1" {
t.Fatalf("no remote addr: got %q", got)
}
if got := clientIP(mk(map[string]string{"X-Real-IP": "::1"}, "")); got != "127.0.0.1" {
t.Fatalf("loopback normalize: got %q", got)
}
}
type EchoIpSvc struct {
Ctx
}
func (s *EchoIpSvc) Get() (string, error) { return s.Ip, nil }
func TestClientIPEndtoEnd(t *testing.T) {
f := New()
if err := f.BindService(&EchoIpSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39015)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
req, err := http.NewRequest("POST", "http://127.0.0.1:39015/cell",
strings.NewReader(`{"serviceName":"EchoIpSvc","methodName":"Get"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-For", "203.0.113.9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out Result[any]
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Status != 0 || out.Data == nil || (*out.Data).(string) != "203.0.113.9" {
t.Fatalf("client ip not honored: %+v", out)
}
}
// ---- 路由 Guard:短路 + 查询参数进 State ----
type RouteTokenGuard struct{}
func (g *RouteTokenGuard) Guard(ctx Ctx) error {
if ctx.State["token"] != "ok" {
return Error(4401, "unauthorized")
}
return nil
}
func TestRouteGuard(t *testing.T) {
f := New()
if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
handlerRan := false
open := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("open")
return nil
}
secret := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("secret")
return nil
}
files := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("file:" + c.Wildcard)
return nil
}
if err := f.BindRoute("GET", "/open", open, &OrderFirstGuard{}); err != nil {
t.Fatal(err)
}
if err := f.BindRoute("GET", "/secret", secret, &OrderRejectGuard{}); err != nil {
t.Fatal(err)
}
if err := f.BindRoute("GET", "/file/*", files, &RouteTokenGuard{}); err != nil {
t.Fatal(err)
}
do := func(path string) *fasthttp.RequestCtx {
fc := &fasthttp.RequestCtx{}
fc.Request.Header.SetMethod("GET")
fc.Request.SetRequestURI(path)
f.handle(fc)
return fc
}
handlerRan = false
if fc := do("/open"); !handlerRan || string(fc.Response.Body()) != "open" {
t.Fatalf("passing guard: ran=%v body=%q", handlerRan, fc.Response.Body())
}
handlerRan = false
fc := do("/secret")
if handlerRan {
t.Fatal("handler must not run when guard rejects")
}
if body := string(fc.Response.Body()); !strings.Contains(body, `"code":4003`) || !strings.Contains(body, `"status":2`) {
t.Fatalf("guard rejection body: %s", body)
}
// 通配路由 + Guard 从查询参数取 tokenState 合并)
handlerRan = false
if fc := do("/file/a/b.txt?token=ok"); !handlerRan || string(fc.Response.Body()) != "file:a/b.txt" {
t.Fatalf("wildcard with token: ran=%v body=%q", handlerRan, fc.Response.Body())
}
handlerRan = false
if fc := do("/file/a.txt"); handlerRan || !strings.Contains(string(fc.Response.Body()), `"code":4401`) {
t.Fatal("missing token must be rejected by route guard")
}
}
// ---- started 护栏 + 优雅停机 ----
func TestStartedGuardAndShutdown(t *testing.T) {
f := New()
if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := uint16(ln.Addr().(*net.TCPAddr).Port)
_ = ln.Close()
go f.Start(port)
time.Sleep(300 * time.Millisecond)
if err := f.Shutdown(context.Background()); err != nil {
t.Fatalf("shutdown: %v", err)
}
if err := f.Shutdown(context.Background()); err != nil {
t.Fatalf("second shutdown should be no-op: %v", err)
}
catch := func(fn func()) (msg string) {
defer func() { msg = fmt.Sprint(recover()) }()
fn()
return ""
}
if m := catch(func() { _ = f.BindService(&TestSvc{}) }); m == "" {
t.Fatal("BindService after Start must panic")
}
if m := catch(func() { _ = f.BindRoute("GET", "/x", func(*RouteCtx) error { return nil }) }); m == "" {
t.Fatal("BindRoute after Start must panic")
}
if m := catch(func() { f.SetTimeouts(time.Second, 0, time.Second) }); m == "" {
t.Fatal("SetTimeouts after Start must panic")
}
}
// ---- 内部错误脱敏:客户端只收固定提示,不泄露 Go 内部细节 ----
func TestInternalErrorsSanitized(t *testing.T) {
f := New()
if err := f.BindService(&PrecisionSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39016)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
post := func(body string) string {
t.Helper()
resp, err := http.Post("http://127.0.0.1:39016/cell", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// data 字段类型错误(字符串进 int64)→ 通用提示,不泄露 unmarshal/类型名
got := post(`{"serviceName":"PrecisionSvc","methodName":"Get","data":{"id":"not-a-number"}}`)
if !strings.Contains(got, "invalid request data") {
t.Fatalf("expected sanitized message, got: %s", got)
}
for _, leak := range []string{"unmarshal", "int64", "PrecisionDto", "Go struct"} {
if strings.Contains(got, leak) {
t.Fatalf("internal detail %q leaked: %s", leak, got)
}
}
// 损坏的请求体 → 通用提示
got = post(`{"serviceName":`)
if !strings.Contains(got, "invalid request body") {
t.Fatalf("expected sanitized body message, got: %s", got)
}
}
// ---- 流式 writer panic 兜底:进程不崩、服务器仍可用 ----
type boomMarshaler struct{}
func (boomMarshaler) MarshalJSON() ([]byte, error) { panic("boom-json") }
type PanicStreamSvc struct{}
func (s *PanicStreamSvc) Go() (*Stream, error) {
st := &Stream{}
go func() {
_ = st.Send(boomMarshaler{})
st.Close()
}()
return st, nil
}
func TestStreamWriterPanicRecovered(t *testing.T) {
f := New()
if err := f.BindService(&PanicStreamSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39017)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
resp, err := http.Post("http://127.0.0.1:39017/cell", "application/json",
strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`))
if err != nil {
t.Fatalf("first stream request: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
// panic 被兜底后服务器必须仍然可用
resp2, err := http.Post("http://127.0.0.1:39017/cell", "application/json",
strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`))
if err != nil {
t.Fatalf("server died after stream writer panic: %v", err)
}
_, _ = io.Copy(io.Discard, resp2.Body)
_ = resp2.Body.Close()
}
// ---- 匿名 struct 注册期拒绝 / isPrivate 空名安全 ----
func TestAnonymousStructRejected(t *testing.T) {
type withAnon struct {
Inner struct{ A string }
}
defer func() {
if recover() == nil {
t.Fatal("anonymous struct must be rejected at registration")
}
}()
checkType(reflect.TypeFor[withAnon]())
_ = isPrivate("") // 空名不再越界 panic
}
// ---- TS 客户端大整数往返(node 真实执行生成物) ----
func TestTypeScriptBigIntRoundTrip(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node is not installed")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
const script = `
import { Client } from "./client.ts";
// 响应侧:mock 返回原文 JSON(不经 JS number),大整数必须解析为 BigInt
globalThis.fetch = async () => new Response('{"status":0,"data":{"id":9007199254740993}}', {
headers: { "Content-Type": "application/json" },
});
const client = new Client("http://example.test");
const result = await client.request("Svc", "BigInt");
if (typeof result.data.id !== "bigint") throw new Error("expected bigint, got " + typeof result.data.id);
if (result.data.id !== 9007199254740993n) throw new Error("bigint value lost");
// 请求侧:dto 里的 BigInt 序列化为数字字面量;普通数值不受影响
let requestBody;
globalThis.fetch = async (_url, init) => {
requestBody = init.body;
return new Response('{"status":0}', { headers: { "Content-Type": "application/json" } });
};
await client.request("Svc", "BigInt", { id: 9007199254740993n });
if (!requestBody.includes('"id":9007199254740993')) throw new Error("bigint not serialized: " + requestBody);
await client.request("Svc", "Small", { id: 42 });
if (!requestBody.includes('"id":42')) throw new Error("small int broken: " + requestBody);
`
scriptPath := filepath.Join(dir, "bigint.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
if output, err := exec.Command(node, "--experimental-strip-types", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("bigint round trip failed: %v\n%s", err, output)
}
}