459 lines
16 KiB
Go
459 lines
16 KiB
Go
package httpx
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
|
|
"spark-server/internal/infrastructure/id"
|
|
"spark-server/internal/infrastructure/logger"
|
|
authentity "spark-server/internal/module/auth/entity"
|
|
authlogic "spark-server/internal/module/auth/logic"
|
|
userlogic "spark-server/internal/module/user/logic"
|
|
usermodel "spark-server/internal/module/user/model"
|
|
"spark-server/internal/pkg/errs"
|
|
"spark-server/internal/pkg/ratelimit"
|
|
"spark-server/internal/pkg/reply"
|
|
"spark-server/internal/pkg/reqctx"
|
|
)
|
|
|
|
func TestHealthzEnvelopeAndTraceHeaders(t *testing.T) {
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{})
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
req.Header.Set("X-Request-ID", "client-should-be-ignored")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d", w.Code)
|
|
}
|
|
got := w.Header().Get("X-Request-ID")
|
|
if len(got) != 32 {
|
|
t.Fatalf("server request id %q headers %v", got, w.Header())
|
|
}
|
|
if w.Header().Get("X-Trace-ID") != "" {
|
|
t.Fatalf("trace header still set %v", w.Header())
|
|
}
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != "0" || env.Msg != "ok" {
|
|
t.Fatalf("%+v", env)
|
|
}
|
|
if env.RequestID != got || len(env.RequestID) != 32 {
|
|
t.Fatalf("body request_id %q header %q", env.RequestID, got)
|
|
}
|
|
if !bytes.Contains(env.Data, []byte(`"status":"ok"`)) {
|
|
t.Fatalf("data %s", env.Data)
|
|
}
|
|
}
|
|
|
|
type stubHealth struct {
|
|
ok bool
|
|
data any
|
|
}
|
|
|
|
func (s stubHealth) Ready(context.Context) (bool, any) { return s.ok, s.data }
|
|
|
|
func TestReadyzHTTPStatus(t *testing.T) {
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: true, data: gin.H{"status": "ok", "mysql": "ok"}}})
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ready %d %s", w.Code, w.Body.String())
|
|
}
|
|
r = NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: false, data: gin.H{"status": "not_ready", "mysql": "down"}}})
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("not ready %d", w.Code)
|
|
}
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != errs.ErrAvailable.Code || !bytes.Contains(env.Data, []byte(`"mysql":"down"`)) {
|
|
t.Fatalf("%+v", env)
|
|
}
|
|
if len(env.RequestID) != 32 {
|
|
t.Fatalf("readyz request_id %q", env.RequestID)
|
|
}
|
|
}
|
|
|
|
func TestDocsAndOpenAPI(t *testing.T) {
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{})
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "openapi: 3.0.3") || !strings.Contains(w.Body.String(), "bearerAuth") {
|
|
t.Fatalf("spec %d %s", w.Code, w.Body.String())
|
|
}
|
|
spec := w.Body.String()
|
|
if !strings.Contains(spec, "name: enums") || !strings.Contains(spec, "name: errors") {
|
|
t.Fatalf("spec missing enum/error tags")
|
|
}
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swagger-ui") || !strings.Contains(w.Body.String(), "/docs/enums") || !strings.Contains(w.Body.String(), "/docs/errors") {
|
|
t.Fatalf("docs %d", w.Code)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/enums", nil))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "id=\"enum-toc\"") {
|
|
t.Fatalf("enums page %d", w.Code)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/errors", nil))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "id=\"error-toc\"") {
|
|
t.Fatalf("errors page %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestJSONErrorEnvelope(t *testing.T) {
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{})
|
|
r.POST("/v1/probe", func(c *gin.Context) {
|
|
JSONError(c, errs.ErrNotFound.WithMsg("missing"))
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/probe", strings.NewReader(`{"token":"secret"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != errs.ErrNotFound.Code || env.Msg != "missing" {
|
|
t.Fatalf("%+v", env)
|
|
}
|
|
}
|
|
|
|
func TestClipBodyRedactsToken(t *testing.T) {
|
|
got := logger.ClipBody([]byte(`{"token":"abc","access_token":"jwt","refresh_token":"rt","verify_code":"654321","upload_url":"http://x?sig=1","x":1}`))
|
|
if strings.Contains(got, "abc") || strings.Contains(got, "jwt") || strings.Contains(got, "rt") || strings.Contains(got, "654321") || strings.Contains(got, "sig=1") || !strings.Contains(got, "***") {
|
|
t.Fatalf("redact %s", got)
|
|
}
|
|
}
|
|
|
|
func testLoginEngine(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids, err := id.New(1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
|
return NewEngine(zap.NewNop(), &EngineDeps{
|
|
Auth: j,
|
|
Login: authlogic.NewLoginService(j, users),
|
|
Users: users,
|
|
})
|
|
}
|
|
|
|
func TestLoginAndBearerAuth(t *testing.T) {
|
|
r := testLoginEngine(t)
|
|
r.GET("/v1/probe", func(c *gin.Context) {
|
|
rc := reqctx.From(c.Request.Context())
|
|
JSON(c, gin.H{"user_id": rc.UserID, "device_id": rc.DeviceID})
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios","device_name":"iPhone"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginReq.Header.Set("X-App-Version", "1.0.0")
|
|
loginReq.Header.Set("X-Real-IP", "203.0.113.10")
|
|
r.ServeHTTP(w, loginReq)
|
|
var loginEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loginEnv.Code != "0" {
|
|
t.Fatalf("login %+v body=%s", loginEnv, w.Body.String())
|
|
}
|
|
var tok authentity.IssuedToken
|
|
if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tok.AccessToken == "" || tok.RefreshToken == "" || tok.UserID <= 0 || tok.DeviceID != 7 || tok.ExpiresIn != 604800 || tok.RefreshExpiresIn != 2592000 || tok.SparkID == "" {
|
|
t.Fatalf("login data %+v", tok)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/probe", nil))
|
|
var denied reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &denied); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if denied.Code != errs.ErrUnauthorized.Code {
|
|
t.Fatalf("no bearer %+v", denied)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
okReq := httptest.NewRequest(http.MethodGet, "/v1/probe", nil)
|
|
okReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
okReq.Header.Set("X-App-Version", "1.0.0")
|
|
okReq.Header.Set("Accept-Language", "zh-CN")
|
|
r.ServeHTTP(w, okReq)
|
|
var okEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &okEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uidJSON, _ := json.Marshal(tok.UserID)
|
|
if okEnv.Code != "0" || !bytes.Contains(okEnv.Data, uidJSON) || !bytes.Contains(okEnv.Data, []byte(`"device_id":7`)) {
|
|
t.Fatalf("authed %+v", okEnv)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
badDev := httptest.NewRequest(http.MethodGet, "/v1/probe", nil)
|
|
badDev.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
badDev.Header.Set("X-Device-ID", "99")
|
|
r.ServeHTTP(w, badDev)
|
|
var mismatch reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &mismatch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mismatch.Code != errs.ErrUnauthorized.Code {
|
|
t.Fatalf("device mismatch %+v", mismatch)
|
|
}
|
|
}
|
|
|
|
func TestUsersMeAndSparkID(t *testing.T) {
|
|
r := testLoginEngine(t)
|
|
w := httptest.NewRecorder()
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, loginReq)
|
|
var loginEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loginEnv.Code != "0" {
|
|
t.Fatalf("login %+v", loginEnv)
|
|
}
|
|
var tok authentity.IssuedToken
|
|
if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
|
|
meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
r.ServeHTTP(w, meReq)
|
|
var meEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hasIDs := bytes.Contains(meEnv.Data, []byte(`"spark_id"`)) && bytes.Contains(meEnv.Data, []byte(`"uid"`)) && bytes.Contains(meEnv.Data, []byte(`"qrcode"`))
|
|
if meEnv.Code != "0" || !hasIDs || bytes.Contains(meEnv.Data, []byte(`spark:u:`)) || bytes.Contains(meEnv.Data, []byte(`spark:q:`)) {
|
|
t.Fatalf("me %+v", meEnv)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
patchReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"spark_id":"alice_ok"}`))
|
|
patchReq.Header.Set("Content-Type", "application/json")
|
|
patchReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
r.ServeHTTP(w, patchReq)
|
|
var patchEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &patchEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if patchEnv.Code != "0" || !bytes.Contains(patchEnv.Data, []byte(`"alice_ok"`)) {
|
|
t.Fatalf("patch %+v", patchEnv)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
profReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"nickname":"Ada","gender":2,"birthday":"1994-05-01"}`))
|
|
profReq.Header.Set("Content-Type", "application/json")
|
|
profReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
r.ServeHTTP(w, profReq)
|
|
var profEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &profEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if profEnv.Code != "0" || !bytes.Contains(profEnv.Data, []byte(`"Ada"`)) || !bytes.Contains(profEnv.Data, []byte(`"1994-05-01"`)) {
|
|
t.Fatalf("profile patch %+v", profEnv)
|
|
}
|
|
}
|
|
|
|
func TestRefreshAndLogout(t *testing.T) {
|
|
r := testLoginEngine(t)
|
|
w := httptest.NewRecorder()
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, loginReq)
|
|
var loginEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loginEnv.Code != "0" {
|
|
t.Fatalf("login %+v", loginEnv)
|
|
}
|
|
var tok authentity.IssuedToken
|
|
if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
refReq := httptest.NewRequest(http.MethodPost, "/v1/auth/token/refresh", strings.NewReader(`{"refresh_token":"`+tok.RefreshToken+`"}`))
|
|
refReq.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, refReq)
|
|
var refEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &refEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if refEnv.Code != "0" {
|
|
t.Fatalf("refresh %+v", refEnv)
|
|
}
|
|
var next authentity.IssuedToken
|
|
if err := json.Unmarshal(refEnv.Data, &next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if next.AccessToken == "" || next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken {
|
|
t.Fatalf("next %+v", next)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
oldMe := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
|
|
oldMe.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
r.ServeHTTP(w, oldMe)
|
|
var oldEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &oldEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if oldEnv.Code != errs.ErrUnauthorized.Code {
|
|
t.Fatalf("old access %+v", oldEnv)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
outReq := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", nil)
|
|
outReq.Header.Set("Authorization", "Bearer "+next.AccessToken)
|
|
r.ServeHTTP(w, outReq)
|
|
var outEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &outEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if outEnv.Code != "0" {
|
|
t.Fatalf("logout %+v", outEnv)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
|
|
meReq.Header.Set("Authorization", "Bearer "+next.AccessToken)
|
|
r.ServeHTTP(w, meReq)
|
|
var meEnv reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meEnv.Code != errs.ErrUnauthorized.Code {
|
|
t.Fatalf("after logout %+v", meEnv)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsBadInput(t *testing.T) {
|
|
r := testLoginEngine(t)
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "请填写验证码") {
|
|
t.Fatalf("%+v", env)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
enReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`))
|
|
enReq.Header.Set("Content-Type", "application/json")
|
|
enReq.Header.Set("Accept-Language", "en")
|
|
r.ServeHTTP(w, enReq)
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "Please enter the verification code") {
|
|
t.Fatalf("en %+v", env)
|
|
}
|
|
}
|
|
|
|
func TestHTTPRealIPOnContext(t *testing.T) {
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{})
|
|
r.GET("/v1/probe-ip", func(c *gin.Context) {
|
|
JSON(c, gin.H{"real_ip": reqctx.RealIP(c.Request.Context())})
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/probe-ip", nil)
|
|
req.Header.Set("X-Real-IP", "203.0.113.10")
|
|
r.ServeHTTP(w, req)
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"real_ip":"203.0.113.10"`)) {
|
|
t.Fatalf("%+v", env)
|
|
}
|
|
}
|
|
|
|
func TestHTTPLanguageOnContext(t *testing.T) {
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{})
|
|
r.GET("/v1/probe-lang", func(c *gin.Context) {
|
|
JSON(c, gin.H{"lang": reqctx.Language(c.Request.Context())})
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/probe-lang", nil)
|
|
req.Header.Set("X-Language", "zh-TW")
|
|
r.ServeHTTP(w, req)
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"lang":"zh-TW"`)) {
|
|
t.Fatalf("%+v", env)
|
|
}
|
|
}
|
|
|
|
func TestLoginRateLimitedByRealIP(t *testing.T) {
|
|
j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids, err := id.New(1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
|
r := NewEngine(zap.NewNop(), &EngineDeps{
|
|
Auth: j,
|
|
Login: authlogic.NewLoginService(j, users),
|
|
Users: users,
|
|
AuthLimiter: ratelimit.New(1),
|
|
})
|
|
body := `{"email":"test01@spark.com","verify_code":"x","device_id":7,"device_type":"ios"}`
|
|
hit := func() reply.Envelope {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Real-IP", "203.0.113.10")
|
|
r.ServeHTTP(w, req)
|
|
var env reply.Envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return env
|
|
}
|
|
if env := hit(); env.Code != "0" {
|
|
t.Fatalf("first %+v", env)
|
|
}
|
|
if env := hit(); env.Code != errs.ErrRateLimited.Code {
|
|
t.Fatalf("second %+v", env)
|
|
}
|
|
}
|