Files
fun/hardening_test.go
T
chiyiandDevin 84c11c6fde v1.4.0: generic Stream[T] — typed stream messages, drop (T, *Stream, error)
- Stream[T]: Send(v T) type-safe; message type recovered from the
  instantiated Send signature (reflect has no type-args API)
- method signatures reduced to (error), (T, error), (*Stream[T], error);
  the (T, *Stream, error) first-message form is gone — send it via Send
- return T and stream message T now run checkType at bind time
  (Stream[any] remains the explicit untyped escape hatch)
- generated clients: TS onMessage: (data: T); Go <-chan T
- fix vacuous panic assertions (fmt.Sprint(nil) -> "<nil>") in
  hardening/route test catch helpers

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-19 15:39:41 +08:00

397 lines
11 KiB
Go
Raw Permalink 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"
)
// ---- 真实 IP:X-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 从查询参数取 token(State 合并)
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() {
if r := recover(); r != nil {
msg = fmt.Sprint(r)
}
}()
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[any], error) {
st := &Stream[any]{}
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)
}
}
// ---- 返回类型 T 与 DTO 同受类型系统约束(含 Stream[T] 的消息类型) ----
type BadRetOnly struct{}
func (*BadRetOnly) Ok() (string, error) { return "ok", nil }
type mapRetSvc struct{}
func (*mapRetSvc) M() (map[string]any, error) { return nil, nil }
type anyRetSvc struct{}
func (*anyRetSvc) M() (any, error) { return nil, nil }
type badStreamSvc struct{}
func (*badStreamSvc) M() (*Stream[map[string]any], error) { return nil, nil }
type anyStreamSvc struct{}
func (*anyStreamSvc) M() (*Stream[any], error) { return &Stream[any]{}, nil }
func TestReturnTypeCheckedAtBind(t *testing.T) {
panics := func(fn func()) (p bool) {
defer func() { p = recover() != nil }()
fn()
return false
}
if panics(func() { _ = New().BindService(&BadRetOnly{}) }) {
t.Fatal("normal service must not panic")
}
for _, tc := range []struct {
name string
svc any
}{
{"map return", &mapRetSvc{}},
{"any return", &anyRetSvc{}},
{"Stream[map]", &badStreamSvc{}},
} {
if !panics(func() { _ = New().BindService(tc.svc) }) {
t.Fatalf("%s must panic at bind", tc.name)
}
}
// Stream[any] 显式任意消息流:放行
if panics(func() { _ = New().BindService(&anyStreamSvc{}) }) {
t.Fatal("Stream[any] should pass")
}
}