From b3cbeef7cff74b9f99b9b0f9827fb111ca0f7821 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 06:59:39 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E7=AC=AC5=E5=88=80a=20L4=20?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=BA=A7=E5=86=92=E7=83=9F=E7=BC=96=E6=8E=92?= =?UTF-8?q?=20+=20HTTP=20=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真起 server 二进制(sqlite + Redis)跑 HTTP 用户旅程,验证 in-process 测试给不了的 信心(二进制起/迁移/路由/鉴权/DB 端到端): - scripts/e2e-smoke.sh:起栈编排(Redis 三选一:已有 addr / docker / 内嵌 miniredis 兜底)+ 随机端口 + trap 清理;调 Go driver - server/test/e2e/smoke_test.go (tag e2e):造用户(复用 argon2 HashPassword) → 登录 → /v1/me(dp_uuid/plan/email) → /v1/usage 鉴权 + 无 token 401 - server/test/e2e/miniredis (tag e2e):无 docker 时 Redis 兜底 launcher - 本地绿;go build/test ./...(无 tag)仍 25 包绿,tag e2e 文件被静默跳过 Co-Authored-By: Claude Opus 4.8 --- scripts/e2e-smoke.sh | 109 ++++++++++++++++++++++ server/test/e2e/miniredis/main.go | 30 ++++++ server/test/e2e/smoke_test.go | 150 ++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100755 scripts/e2e-smoke.sh create mode 100644 server/test/e2e/miniredis/main.go create mode 100644 server/test/e2e/smoke_test.go diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh new file mode 100755 index 0000000..9accd85 --- /dev/null +++ b/scripts/e2e-smoke.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# e2e-smoke.sh — L4 后端进程级端到端冒烟。 +# +# 真起打包好的 server 二进制(sqlite 临时库 + Redis),跑一条真 HTTP/gRPC over TCP/TLS +# 的用户旅程,验证 in-process 测试给不了的信心:二进制能起、迁移能跑、路由/鉴权/DB +# 端到端通、用量真入库真从 API 读出。 +# +# 用法: bash scripts/e2e-smoke.sh +# 依赖: go · openssl · python3(取随机端口) · curl +# Redis 三选一:E2E_REDIS_ADDR(已有) > docker(redis:7-alpine) > 内嵌 miniredis +# +# 阶段: 5a = HTTP 段(登录→/v1/me→/v1/usage);5b 起 gRPC 全链路(enroll+ReportUsage)。 +# 全部断言在 Go driver: server/test/e2e/smoke_test.go(build tag e2e)。 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TMP="$(mktemp -d)" +REDIS_CID="" +MR_PID="" +SERVER_PID="" +REDIS_ADDR_RESOLVED="" +cleanup() { + if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true; fi + if [ -n "$MR_PID" ]; then kill "$MR_PID" 2>/dev/null || true; wait "$MR_PID" 2>/dev/null || true; fi + pkill -f 'test/e2e/miniredis' 2>/dev/null || true + [ -n "$REDIS_CID" ] && docker rm -f "$REDIS_CID" >/dev/null 2>&1 || true + rm -rf "$TMP" +} +trap cleanup EXIT + +rand_port() { + python3 -c 'import socket;s=socket.socket();s.bind(("",0));print(s.getsockname()[1]);s.close()' +} + +# start_redis — 按优先级落一个真 TCP Redis,地址写入 REDIS_ADDR_RESOLVED。 +start_redis() { + if [ -n "${E2E_REDIS_ADDR:-}" ]; then + REDIS_ADDR_RESOLVED="$E2E_REDIS_ADDR" + echo "[e2e] 用已有 Redis $REDIS_ADDR_RESOLVED" + return + fi + if docker info >/dev/null 2>&1; then + local p; p="$(rand_port)" + REDIS_CID="$(docker run -d -p "$p:6379" redis:7-alpine)" + REDIS_ADDR_RESOLVED="127.0.0.1:$p" + echo "[e2e] docker Redis $REDIS_ADDR_RESOLVED" + return + fi + echo "[e2e] 无 docker,用内嵌 miniredis 兜底 ..." + ( cd server && go run -tags e2e ./test/e2e/miniredis ) > "$TMP/mr.addr" 2>"$TMP/mr.log" & + MR_PID=$! + for _ in $(seq 1 120); do [ -s "$TMP/mr.addr" ] && break; sleep 0.25; done + REDIS_ADDR_RESOLVED="$(head -1 "$TMP/mr.addr" 2>/dev/null || true)" + [ -n "$REDIS_ADDR_RESOLVED" ] || { echo "[e2e] ✗ miniredis 起失败:" >&2; cat "$TMP/mr.log" >&2; exit 1; } + echo "[e2e] miniredis Redis $REDIS_ADDR_RESOLVED" +} + +HTTP_PORT="$(rand_port)" + +start_redis + +echo "[e2e] 生成 JWT 密钥 ..." +openssl genrsa -out "$TMP/jwt_private.pem" 2048 2>/dev/null +openssl rsa -in "$TMP/jwt_private.pem" -pubout -out "$TMP/jwt_public.pem" 2>/dev/null + +echo "[e2e] go build server + migrate ..." +( cd server && go build -o "$TMP/server" ./cmd/server && go build -o "$TMP/migrate" ./cmd/migrate ) + +DB_FILE="$TMP/e2e.db" +echo "[e2e] migrate(sqlite) ..." +DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$TMP/migrate" up + +echo "[e2e] 起 server :$HTTP_PORT ..." +DB_DRIVER=sqlite DB_DSN="$DB_FILE" \ + REDIS_ADDR="$REDIS_ADDR_RESOLVED" \ + ADDR=":$HTTP_PORT" \ + JWT_PRIVATE_KEY_PATH="$TMP/jwt_private.pem" \ + JWT_KEY_ID="e2e-key-1" \ + JWT_PUBLIC_KEYS="e2e-key-1:$TMP/jwt_public.pem" \ + WEBHOOK_SECRET="e2e-webhook-secret-000000000000000000" \ + NODE_DERIVE_KEY="e2e-derive-key-0000000000000000000000" \ + "$TMP/server" > "$TMP/server.log" 2>&1 & +SERVER_PID=$! + +echo "[e2e] 等待 /healthz ..." +ok=0 +for _ in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:$HTTP_PORT/healthz" >/dev/null 2>&1; then ok=1; break; fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "[e2e] ✗ server 启动即退出,日志:" >&2; cat "$TMP/server.log" >&2; exit 1 + fi + sleep 0.5 +done +[ "$ok" = 1 ] || { echo "[e2e] ✗ /healthz 超时,日志:" >&2; cat "$TMP/server.log" >&2; exit 1; } +echo "[e2e] 控制面健康。" + +echo "[e2e] 跑 Go driver ..." +( + cd server + E2E_HTTP="http://127.0.0.1:$HTTP_PORT" \ + E2E_DB_DSN="$DB_FILE" \ + E2E_SERVER_LOG="$TMP/server.log" \ + DB_DRIVER=sqlite \ + go test -tags e2e ./test/e2e -run TestE2ESmoke -count=1 -v +) + +echo "[e2e] ✓ PASS — 进程级端到端冒烟通过" diff --git a/server/test/e2e/miniredis/main.go b/server/test/e2e/miniredis/main.go new file mode 100644 index 0000000..4e81347 --- /dev/null +++ b/server/test/e2e/miniredis/main.go @@ -0,0 +1,30 @@ +//go:build e2e + +// Command miniredis 起一个进程内的 miniredis 实例,打印监听地址(host:port)到 stdout +// 然后阻塞,直到收到 SIGINT/SIGTERM。供 scripts/e2e-smoke.sh 在无 docker / 无本地 +// redis-server 时作为 Redis 兜底——server 进程经真 TCP 连它。build tag e2e:不进常规构建。 +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/alicebob/miniredis/v2" +) + +func main() { + m, err := miniredis.Run() + if err != nil { + fmt.Fprintln(os.Stderr, "miniredis:", err) + os.Exit(1) + } + // 第一行 = 监听地址,脚本读它作为 REDIS_ADDR。 + fmt.Println(m.Addr()) + + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + <-ch + m.Close() +} diff --git a/server/test/e2e/smoke_test.go b/server/test/e2e/smoke_test.go new file mode 100644 index 0000000..e531a7d --- /dev/null +++ b/server/test/e2e/smoke_test.go @@ -0,0 +1,150 @@ +//go:build e2e + +// Package e2e 是 L4 进程级端到端冒烟的 driver(build tag e2e,不进常规 go test ./...)。 +// 由 scripts/e2e-smoke.sh 起好真 server 进程后,经 E2E_* env 指向真 HTTP/gRPC 地址驱动。 +// +// 5a:HTTP 段(造用户 → 登录 → /v1/me → /v1/usage)。5b 追加 gRPC 全链路。 +package e2e + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/auth" + "github.com/wangjia/pangolin/server/internal/db" +) + +const ( + e2eEmail = "e2e@example.com" + e2ePass = "e2e-pass-12345" + e2eDPUUID = "e2e-dp-uuid-00000001" +) + +func TestE2ESmoke(t *testing.T) { + base := os.Getenv("E2E_HTTP") + dsn := os.Getenv("E2E_DB_DSN") + if base == "" || dsn == "" { + t.Fatal("E2E_HTTP / E2E_DB_DSN 未设——请经 scripts/e2e-smoke.sh 运行") + } + + seedUser(t, dsn) + + // ── HTTP 用户旅程 ────────────────────────────────────────────── + token := login(t, base, e2eEmail, e2ePass) + if token == "" { + t.Fatal("登录未返回 access_token") + } + + me := getJSON(t, base, "/v1/me", token) + if got := me["dp_uuid"]; got != e2eDPUUID { + t.Errorf("/v1/me dp_uuid = %v, want %s", got, e2eDPUUID) + } + if got := me["plan"]; got != "free" { + t.Errorf("/v1/me plan = %v, want free", got) + } + if got := me["email"]; got != e2eEmail { + t.Errorf("/v1/me email = %v, want %s", got, e2eEmail) + } + + // /v1/usage 鉴权通过 + 可解析(初始无流量;具体 bytes 断言见 5b 注入用量后)。 + assertStatus200(t, base, "/v1/usage", token) + assertStatus200(t, base, "/v1/usage/devices", token) + + // 无 token → 401(鉴权真生效)。 + assertUnauthorized(t, base, "/v1/me") + + t.Log("✓ HTTP 段通过:登录 → /v1/me(dp_uuid/plan/email) → /v1/usage(鉴权)") +} + +// seedUser 直接写 sqlite 造一个 active 用户(复用 argon2id HashPassword), +// 比走注册验证码稳。server 刚起基本空闲,一次性 INSERT 锁风险极低。 +func seedUser(t *testing.T, dsn string) { + t.Helper() + conn, err := db.Open(dsn) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + defer conn.Close() + hash, err := auth.HashPassword(e2ePass) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if _, err := conn.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) VALUES (?, ?, ?, ?, 'active')`, + "e2e-user-uuid-0001", e2eEmail, hash, e2eDPUUID, + ); err != nil { + t.Fatalf("seed user: %v", err) + } +} + +func login(t *testing.T, base, email, pass string) string { + t.Helper() + body, _ := json.Marshal(map[string]string{"email": email, "password": pass}) + resp, err := httpClient().Post(base+"/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("login POST: %v", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login status = %d, body = %s", resp.StatusCode, raw) + } + var tp struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(raw, &tp); err != nil { + t.Fatalf("login decode: %v (body=%s)", err, raw) + } + return tp.AccessToken +} + +func getJSON(t *testing.T, base, path, token string) map[string]any { + t.Helper() + raw, status := doGet(t, base, path, token) + if status != http.StatusOK { + t.Fatalf("GET %s status = %d, body = %s", path, status, raw) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("GET %s decode: %v (body=%s)", path, err, raw) + } + return m +} + +func assertStatus200(t *testing.T, base, path, token string) { + t.Helper() + raw, status := doGet(t, base, path, token) + if status != http.StatusOK { + t.Errorf("GET %s status = %d, want 200, body = %s", path, status, raw) + } +} + +func assertUnauthorized(t *testing.T, base, path string) { + t.Helper() + _, status := doGet(t, base, path, "") + if status != http.StatusUnauthorized { + t.Errorf("GET %s (无 token) status = %d, want 401", path, status) + } +} + +func doGet(t *testing.T, base, path, token string) ([]byte, int) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, base+path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := httpClient().Do(req) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + return raw, resp.StatusCode +} + +func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Second} }