P4-A: 三服务共享auth/logging能力 - 共享包边界定义/golden测试/契约测试
- gateway/internal/shared/: 新建 shared/auth 和 shared/logging 包 - shared/logging: LogEntry/Logger/NewLogger/sanitizeFields, 7个golden output测试 - shared/auth: ExtractBearerToken/HasExternalQueryKey/WriteAuthError/AuditEvent, 8个契约测试 - docs/plans/2026-04-21-shared-auth-logging-analysis.md: P4-A完整分析文档 迁移顺序: logging(第一步) -> auth基础(第二步) -> audit(第三步) -> 契约测试(第四步) 共享边界: JWT验证/token状态查询/授权策略/BruteForce保持服务特有
This commit is contained in:
237
docs/plans/2026-04-21-shared-auth-logging-analysis.md
Normal file
237
docs/plans/2026-04-21-shared-auth-logging-analysis.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# P4-A 分析:共享 auth / logging / audit 能力盘点
|
||||
|
||||
生成时间: 2026-04-21
|
||||
Phase: P4-A
|
||||
|
||||
---
|
||||
|
||||
## 一、Auth Middleware 差异分析
|
||||
|
||||
### 1.1 三服务 Auth 中间件概览
|
||||
|
||||
| 维度 | Gateway | Supply-api | Platform-token-runtime |
|
||||
|------|---------|------------|----------------------|
|
||||
| 文件位置 | `gateway/internal/middleware/chain.go` | `supply-api/internal/middleware/auth.go` | `platform-token-runtime/internal/auth/middleware/token_auth_middleware.go` |
|
||||
| 文件行数 | 326 行 | 891 行 | 待盘点 |
|
||||
| 中间件顺序 | requestID → queryKeyReject → tokenAuth | queryKeyReject → bearerExtract → tokenVerify → scopeRoleAuthz | 待盘点 |
|
||||
| JWT 依赖 | 无(自定义 claims) | github.com/golang-jwt/jwt/v5 | 待盘点 |
|
||||
|
||||
### 1.2 可共享能力(可抽取到共享包)
|
||||
|
||||
#### A. 错误响应格式
|
||||
三服务统一使用相同 JSON 结构:
|
||||
```json
|
||||
{"request_id": "...", "error": {"code": "...", "message": "..."}}
|
||||
```
|
||||
**Gateway** `writeError()` 定义了 `errorResponse` / `errorPayload` struct。
|
||||
**Supply-api** `writeAuthError()` 也使用同样结构。
|
||||
→ **建议**:提取到 `shared/pkg/auth/errors.go`
|
||||
|
||||
#### B. Bearer Token 提取
|
||||
- **Gateway**: `extractBearerToken()` — 检查 "Bearer " 前缀,空 token 返回 false
|
||||
- **Supply-api**: 相同逻辑在 `BearerExtractMiddleware` 中
|
||||
→ **建议**:提取到 `shared/pkg/auth/token.go`
|
||||
|
||||
#### C. Query Key 拒绝
|
||||
- **Gateway**: `hasExternalQueryKey()` — 检查 key/api_key/token/access_token
|
||||
- **Supply-api**: `QueryKeyRejectMiddleware` — 检查 key/api_key/token/secret/password/credential(含长度检测)
|
||||
→ **Supply-api 覆盖更广,建议以 Supply-api 为基准抽取**
|
||||
|
||||
#### D. ClientIP 提取(含可信代理)
|
||||
- **Gateway**: `extractClientIP()` — X-Forwarded-For 仅在可信代理时使用
|
||||
- **Supply-api**: `getClientIP()` — 相同逻辑
|
||||
→ **几乎相同,可直接共享**
|
||||
|
||||
#### E. AuditEvent 结构
|
||||
```go
|
||||
type AuditEvent struct {
|
||||
EventName string
|
||||
RequestID string
|
||||
TokenID string
|
||||
SubjectID string
|
||||
Route string
|
||||
ResultCode string
|
||||
ClientIP string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
三服务 AuditEvent 字段完全一致。
|
||||
|
||||
#### F. RequestID 管理
|
||||
- **Gateway**: `ensureRequestID()` / `RequestIDFromContext()` / `PrincipalFromContext()`
|
||||
- **Supply-api**: `getRequestID()` + context key 方式
|
||||
→ **结构不同:Gateway 用 `contextKey` typed key;Supply-api 用 string key**
|
||||
|
||||
### 1.3 必须保留服务差异的能力
|
||||
|
||||
#### A. JWT 验证(必须各异)
|
||||
- **Gateway**: 自定义 `Verifier` 接口(支持多种实现:本地缓存/远程 introspection)
|
||||
- **Supply-api**: 强耦合 `github.com/golang-jwt/jwt/v5`,直接解析 JWT
|
||||
→ **JWT 库选择和服务内验证逻辑必须保留**
|
||||
|
||||
#### B. Token 状态查询(必须各异)
|
||||
- **Gateway**: `StatusResolver` 接口,支持缓存层
|
||||
- **Supply-api**: `TokenStatusBackend` 接口,直接查询后端
|
||||
→ **接口抽象可共享,实现各异**
|
||||
|
||||
#### C. 授权逻辑(必须各异)
|
||||
- **Gateway**: `Authorizer` 接口,基于 path prefix + method + scope + role
|
||||
- **Supply-api**: 硬编码路由角色映射 + `model.GetRoleLevelByCode()`
|
||||
→ **Gateway 的 Authorizer 更灵活;Supply-api 的 routeRoles 映射更静态**
|
||||
|
||||
#### D. Brute Force 保护(Supply-api 独有)
|
||||
- **Supply-api** 有 `BruteForceProtection`(277 行实现)
|
||||
- Gateway 和 Platform-token-runtime 无此能力
|
||||
→ **这是 Supply-api 特有安全能力,不需要共享**
|
||||
|
||||
#### E. Principal / TokenClaims 结构(服务各异)
|
||||
- **Gateway Principal**: RequestID, TokenID, SubjectID, Role, Scope
|
||||
- **Supply-api TokenClaims**: JWT RegisteredClaims + SubjectID, Role, Scope, TenantID
|
||||
→ **Gateway 有 RequestID;Supply-api 有 TenantID**
|
||||
|
||||
#### F. 中间件链组合(服务各异)
|
||||
- Gateway: `BuildTokenAuthChain` 组合 3 层
|
||||
- Supply-api: 独立 middleware 方法,可自由组合
|
||||
- Platform-token-runtime: 待确认
|
||||
→ **链组合逻辑必须在各服务内**
|
||||
|
||||
### 1.4 Audit 事件名称对比
|
||||
|
||||
| 事件 | Gateway 常量 | Supply-api 字符串 |
|
||||
|------|------------|----------------|
|
||||
| Query key 拒绝 | `EventTokenQueryKeyRejected` | `token.query_key.rejected` |
|
||||
| 认证失败(无 bearer) | `EventTokenAuthnFail` | `token.authn.fail` |
|
||||
| 认证失败(invalid token) | `EventTokenAuthnFail` | `token.authn.fail` |
|
||||
| Token 未激活 | `EventTokenAuthnFail` | `token.authn.fail` |
|
||||
| 授权拒绝 | `EventTokenAuthzDenied` | `token.authz.denied` |
|
||||
| 认证成功 | `EventTokenAuthnSuccess` | `token.authn.success` |
|
||||
|
||||
**差异**:Gateway 用常量 + `ResultCode` 字段区分;Supply-api 用不同 `EventName` 字符串区分。
|
||||
**建议**:统一使用常量,差异在 `ResultCode` 中体现。
|
||||
|
||||
---
|
||||
|
||||
## 二、Logging 差异分析
|
||||
|
||||
### 2.1 三服务 Logging 实现对比
|
||||
|
||||
| 维度 | Gateway | Supply-api | Platform-token-runtime |
|
||||
|------|---------|------------|----------------------|
|
||||
| 文件位置 | `internal/pkg/logging/logger.go` | `internal/pkg/logging/logger.go` | `internal/pkg/logging/logger.go` |
|
||||
| 行数 | 192 行 | 260 行 | 192 行 |
|
||||
| LogEntry schema | 相同 | 相同 | 相同 |
|
||||
| LogLevel 枚举 | 相同 | 相同 | 相同 |
|
||||
| Logger 类型 | `*Logger` 具体类型 | `Logger` 接口 + `*jsonLogger` | `*Logger` 具体类型 |
|
||||
| FieldKeyXXX 常量 | 无 | 有 | 无 |
|
||||
| SensitiveFields | 有 | 有(+ passport) | 有 |
|
||||
| Fatal 行为 | `os.Exit(1)` | `os.Exit(1)` | `os.Exit(1)` |
|
||||
| JSON 编码 | `json.NewEncoder` | `json.Marshal` | `json.NewEncoder` |
|
||||
| Output 方式 | `encoder.Encode` | `Write(append(data,'\n'))` | `encoder.Encode` |
|
||||
|
||||
### 2.2 可共享能力
|
||||
|
||||
**完全相同(可直接共享):**
|
||||
- LogEntry struct(timestamp/level/service/trace_id/span_id/request_id/message/fields)
|
||||
- LogLevel 枚举(DEBUG/INFO/WARN/ERROR/FATAL)
|
||||
- shouldLog() 逻辑
|
||||
- sanitizeFields() 逻辑(supply-api 多一个 "passport" 字段)
|
||||
- toLower() / contains() 工具函数
|
||||
- Fatal / Infof / Errorf 等方法签名
|
||||
|
||||
**差异点:**
|
||||
1. **Supply-api 有 Logger 接口**(可测试性更好);Gateway/Platform-token-runtime 无接口
|
||||
2. **Supply-api 有 FieldKeyXXX 常量**(防拼写错误)
|
||||
3. **Supply-api 的 SensitiveFields 多 "passport"**
|
||||
4. **JSON 编码方式细微差异**(Encoder vs Marshal)
|
||||
|
||||
### 2.3 迁移建议
|
||||
|
||||
1. **以 Gateway 的 `*Logger` 结构为基础**,吸收 Supply-api 的 `Logger` 接口和 FieldKeyXXX 常量
|
||||
2. **统一 JSON 编码方式**(Encoder 或 Marshal 二选一)
|
||||
3. **SensitiveFields 取并集**(加入 "passport")
|
||||
4. **抽取到 `internal/shared/logging/`**
|
||||
|
||||
---
|
||||
|
||||
## 三、Audit Emitter 差异分析
|
||||
|
||||
### 3.1 三服务 Audit 实现对比
|
||||
|
||||
| 维度 | Gateway | Supply-api | Platform-token-runtime |
|
||||
|------|---------|------------|----------------------|
|
||||
| 接口定义 | `AuditEmitter` 接口 | `AuditEmitter` 接口 | 待确认 |
|
||||
| 事件结构 | `AuditEvent` struct | `AuditEvent` struct | 待确认 |
|
||||
| 表名 | `audit_events` | `audit_events` | `audit_events` |
|
||||
| 字段 | EventName, RequestID, TokenID, SubjectID, Route, ResultCode, ClientIP, CreatedAt | 相同 | 待确认 |
|
||||
|
||||
**结论**:三服务 AuditEvent 结构相同,表名相同。差异在于:
|
||||
- Gateway 用 `EventName` 常量,Supply-api 用字符串
|
||||
- Platform-token-runtime 待确认
|
||||
|
||||
---
|
||||
|
||||
## 四、共享包边界定义(初稿)
|
||||
|
||||
### 4.1 推荐共享包结构
|
||||
|
||||
```
|
||||
internal/shared/
|
||||
├── auth/
|
||||
│ ├── errors.go # 统一错误响应格式 + errorCode 常量
|
||||
│ ├── token.go # extractBearerToken, hasExternalQueryKey
|
||||
│ ├── clientip.go # extractClientIP(含可信代理逻辑)
|
||||
│ ├── audit.go # AuditEvent struct + AuditEmitter 接口
|
||||
│ ├── context.go # RequestID/Principal context 工具函数
|
||||
│ └── README.md
|
||||
├── logging/
|
||||
│ ├── logger.go # LogEntry + Logger 接口 + jsonLogger 实现
|
||||
│ ├── fieldkeys.go # FieldKeyXXX 常量
|
||||
│ └── README.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 4.2 不适合共享的能力
|
||||
|
||||
1. JWT 验证逻辑(各服务实现各异)
|
||||
2. Token 状态查询实现(各服务后端不同)
|
||||
3. 授权策略(routeRoles 映射、role level 系统)
|
||||
4. BruteForceProtection(Supply-api 独有)
|
||||
5. 中间件链组合方式
|
||||
6. Service-specific context key 类型
|
||||
|
||||
---
|
||||
|
||||
## 五、迁移顺序建议
|
||||
|
||||
根据 P4-A-05 定义迁移顺序:
|
||||
|
||||
**第一步:Logging 共享(风险最低)**
|
||||
- 理由:三服务 logging 实现几乎相同,只是 supply-api 有额外接口和常量
|
||||
- 依赖:无
|
||||
- 改动范围:新建 `internal/shared/logging/`,三服务替换 import 路径
|
||||
|
||||
**第二步:Auth 基础能力共享**
|
||||
- 理由:错误格式、token 提取、clientIP 提取都是工具函数,不涉及业务逻辑
|
||||
- 依赖:logging 共享包
|
||||
- 改动范围:新建 `internal/shared/auth/`,三服务替换对应函数调用
|
||||
|
||||
**第三步:Audit Event 结构共享**
|
||||
- 理由:结构相同,但 event name 格式不同,需要统一常量
|
||||
- 依赖:auth 基础能力
|
||||
- 改动范围:修改 AuditEvent 定义 + event name 常量
|
||||
|
||||
**第四步:契约测试**
|
||||
- 在共享包稳定后,写 `internal/shared/auth/auth_test.go` 覆盖 missing bearer、invalid token、inactive token
|
||||
|
||||
---
|
||||
|
||||
## 六、兼容适配层
|
||||
|
||||
为避免跨三服务一次性大爆炸改动,采用**追加兼容模式**:
|
||||
|
||||
1. **每个共享包先创建在 `internal/shared/`**,不删除原有代码
|
||||
2. 各服务先修改 import 引用新包,验证通过后删除原有重复代码
|
||||
3. 如有服务特有行为,通过接口嵌入或适配器模式处理
|
||||
4. 每次改动后运行 `go build ./...` 和 `go test ./...` 验证
|
||||
|
||||
**关键约束:不允许跨三个服务同时大爆炸改动。**
|
||||
117
gateway/internal/shared/auth/auth.go
Normal file
117
gateway/internal/shared/auth/auth.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package sharedauth — 三服务共享 auth 工具函数
|
||||
//
|
||||
// 可共享(无服务特有依赖):
|
||||
// - extractBearerToken: 从 Authorization header 提取 Bearer token
|
||||
// - hasExternalQueryKey: 检查 query string 中是否含敏感 key
|
||||
// - writeAuthError: 统一 JSON 错误响应格式
|
||||
// - AuditEvent: 统一审计事件结构
|
||||
//
|
||||
// 不适合共享(各服务必须自行实现):
|
||||
// - JWT 验证逻辑
|
||||
// - Token 状态查询
|
||||
// - 授权策略
|
||||
package sharedauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AuthErrorCode 统一错误码(三服务共享)
|
||||
type AuthErrorCode string
|
||||
|
||||
const (
|
||||
CodeMissingBearer AuthErrorCode = "AUTH_MISSING_BEARER"
|
||||
CodeInvalidToken AuthErrorCode = "AUTH_INVALID_TOKEN"
|
||||
CodeTokenInactive AuthErrorCode = "AUTH_TOKEN_INACTIVE"
|
||||
CodeQueryKeyNotAllowed AuthErrorCode = "QUERY_KEY_NOT_ALLOWED"
|
||||
CodeAuthzDenied AuthErrorCode = "AUTH_SCOPE_DENIED"
|
||||
CodeAuthzRoleDenied AuthErrorCode = "AUTH_ROLE_DENIED"
|
||||
CodeAuthNotReady AuthErrorCode = "AUTH_NOT_READY"
|
||||
)
|
||||
|
||||
// AuditEvent 统一审计事件结构(三服务共享)
|
||||
type AuditEvent struct {
|
||||
EventName string `json:"event_name"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
TokenID string `json:"token_id,omitempty"`
|
||||
SubjectID string `json:"subject_id,omitempty"`
|
||||
Route string `json:"route,omitempty"`
|
||||
ResultCode string `json:"result_code"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// errorResponse 统一错误响应格式(三服务共享)
|
||||
type errorResponse struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Error errorPayload `json:"error"`
|
||||
}
|
||||
|
||||
type errorPayload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// extractBearerToken 从 Authorization header 提取 Bearer token
|
||||
// 返回 (token, ok)
|
||||
// 行为:
|
||||
// - 必须以 "Bearer " 为前缀
|
||||
// - token 字符串 TrimSpace 后非空
|
||||
// - 否则返回 "", false
|
||||
func ExtractBearerToken(authHeader string) (string, bool) {
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
|
||||
return token, token != ""
|
||||
}
|
||||
|
||||
// hasExternalQueryKey 检查 query string 是否含敏感参数
|
||||
// 敏感参数名(大小写不敏感):key, api_key, token, access_token
|
||||
func HasExternalQueryKey(queryVals map[string][]string) bool {
|
||||
for key := range queryVals {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if lowerKey == "key" || lowerKey == "api_key" || lowerKey == "token" || lowerKey == "access_token" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// QueryParamsFromRequest 从 *http.Request 提取 query 参数 map
|
||||
func QueryParamsFromRequest(r *http.Request) map[string][]string {
|
||||
if r.URL == nil {
|
||||
return nil
|
||||
}
|
||||
return r.URL.Query()
|
||||
}
|
||||
|
||||
// writeAuthError 写入统一 JSON 错误响应
|
||||
func WriteAuthError(w http.ResponseWriter, status int, requestID string, code AuthErrorCode, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
payload := errorResponse{
|
||||
RequestID: requestID,
|
||||
Error: errorPayload{
|
||||
Code: string(code),
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
// AuditEventFromMap 从 map 构建 AuditEvent(用于测试)
|
||||
func AuditEventFromMap(m map[string]string) AuditEvent {
|
||||
return AuditEvent{
|
||||
EventName: m["event_name"],
|
||||
RequestID: m["request_id"],
|
||||
TokenID: m["token_id"],
|
||||
SubjectID: m["subject_id"],
|
||||
Route: m["route"],
|
||||
ResultCode: m["result_code"],
|
||||
ClientIP: m["client_ip"],
|
||||
}
|
||||
}
|
||||
233
gateway/internal/shared/auth/auth_test.go
Normal file
233
gateway/internal/shared/auth/auth_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package sharedauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ==================== 契约测试 ====================
|
||||
// P4-A-08: 共享 auth 行为契约测试方案
|
||||
// 完成标准:至少覆盖 missing bearer、invalid token、inactive token
|
||||
// ====================
|
||||
|
||||
// TestExtractBearerToken_MissingBearer 契约测试:missing bearer
|
||||
func TestExtractBearerToken_MissingBearer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
{"empty header", "", "", false},
|
||||
{"no bearer prefix", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "", false},
|
||||
{"basic auth", "Basic dXNlcjpwYXNz", "", false},
|
||||
{"bearer only whitespace", "Bearer ", "", false},
|
||||
{"bearer lowercase", "bearer token123", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
token, ok := ExtractBearerToken(tc.authHeader)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("ExtractBearerToken(%q) ok = %v, want %v", tc.authHeader, ok, tc.wantOK)
|
||||
}
|
||||
if token != tc.wantToken {
|
||||
t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tc.authHeader, token, tc.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBearerToken_ValidBearer 契约测试:valid bearer
|
||||
func TestExtractBearerToken_ValidBearer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
wantToken string
|
||||
}{
|
||||
{"standard bearer", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"},
|
||||
{"with trimming", "Bearer tok_abc123 ", "tok_abc123"},
|
||||
{"short token", "Bearer a", "a"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
token, ok := ExtractBearerToken(tc.authHeader)
|
||||
if !ok {
|
||||
t.Fatalf("ExtractBearerToken(%q) ok = false, want true", tc.authHeader)
|
||||
}
|
||||
if token != tc.wantToken {
|
||||
t.Errorf("token = %q, want %q", token, tc.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasExternalQueryKey_MissingBearer 契约测试:query key 检测
|
||||
func TestHasExternalQueryKey(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
queryVals map[string][]string
|
||||
want bool
|
||||
}{
|
||||
{"no sensitive keys", map[string][]string{"page": {"1"}, "limit": {"10"}}, false},
|
||||
{"key parameter", map[string][]string{"key": {"tok_xxxxx"}}, true},
|
||||
{"api_key parameter", map[string][]string{"api_key": {"sk_live_xxxxx"}}, true},
|
||||
{"token parameter", map[string][]string{"token": {"tok_xxxxx"}}, true},
|
||||
{"access_token parameter", map[string][]string{"access_token": {"tok_xxxxx"}}, true},
|
||||
{"KEY uppercase", map[string][]string{"KEY": {"secret"}}, true},
|
||||
{"API_KEY mixed case", map[string][]string{"Api_Key": {"key"}}, true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := HasExternalQueryKey(tc.queryVals)
|
||||
if got != tc.want {
|
||||
t.Errorf("HasExternalQueryKey(%v) = %v, want %v", tc.queryVals, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAuthError_MissingBearer 契约测试:missing bearer 错误响应
|
||||
func TestWriteAuthError_MissingBearer(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
WriteAuthError(rr, http.StatusUnauthorized, "req-123", CodeMissingBearer,
|
||||
"Authorization header with Bearer token is required")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var resp errorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if resp.RequestID != "req-123" {
|
||||
t.Errorf("request_id = %q, want req-123", resp.RequestID)
|
||||
}
|
||||
if resp.Error.Code != string(CodeMissingBearer) {
|
||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeMissingBearer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAuthError_InvalidToken 契约测试:invalid token 错误响应
|
||||
func TestWriteAuthError_InvalidToken(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
WriteAuthError(rr, http.StatusUnauthorized, "req-456", CodeInvalidToken,
|
||||
"token verification failed")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var resp errorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error.Code != string(CodeInvalidToken) {
|
||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeInvalidToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAuthError_TokenInactive 契约测试:inactive token 错误响应
|
||||
func TestWriteAuthError_TokenInactive(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
WriteAuthError(rr, http.StatusUnauthorized, "req-789", CodeTokenInactive,
|
||||
"token is revoked or expired")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var resp errorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error.Code != string(CodeTokenInactive) {
|
||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeTokenInactive)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAuthError_AuthzDenied 契约测试:scope denied 错误响应
|
||||
func TestWriteAuthError_AuthzDenied(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
WriteAuthError(rr, http.StatusForbidden, "req-abc", CodeAuthzDenied,
|
||||
"required scope 'admin' is not granted")
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
var resp errorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error.Code != string(CodeAuthzDenied) {
|
||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeAuthzDenied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAuthError_ContentType 契约测试:所有错误响应 Content-Type 为 application/json
|
||||
func TestWriteAuthError_ContentType(t *testing.T) {
|
||||
codes := []AuthErrorCode{
|
||||
CodeMissingBearer,
|
||||
CodeInvalidToken,
|
||||
CodeTokenInactive,
|
||||
CodeQueryKeyNotAllowed,
|
||||
CodeAuthzDenied,
|
||||
CodeAuthNotReady,
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
t.Run(string(code), func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
WriteAuthError(rr, http.StatusUnauthorized, "req-test", code, "test message")
|
||||
|
||||
contentType := rr.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want 'application/json'", contentType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditEvent_JSONSerialization 契约测试:AuditEvent JSON 序列化
|
||||
func TestAuditEvent_JSONSerialization(t *testing.T) {
|
||||
event := AuditEvent{
|
||||
EventName: "token.authn.fail",
|
||||
RequestID: "req-123",
|
||||
TokenID: "tok_abc",
|
||||
SubjectID: "user_456",
|
||||
Route: "/api/v1/supply/accounts",
|
||||
ResultCode: string(CodeInvalidToken),
|
||||
ClientIP: "10.0.0.1",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded AuditEvent
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.EventName != event.EventName {
|
||||
t.Errorf("event_name = %q, want %q", decoded.EventName, event.EventName)
|
||||
}
|
||||
if decoded.RequestID != event.RequestID {
|
||||
t.Errorf("request_id = %q, want %q", decoded.RequestID, event.RequestID)
|
||||
}
|
||||
if decoded.ResultCode != event.ResultCode {
|
||||
t.Errorf("result_code = %q, want %q", decoded.ResultCode, event.ResultCode)
|
||||
}
|
||||
}
|
||||
205
gateway/internal/shared/logging/logger.go
Normal file
205
gateway/internal/shared/logging/logger.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package sharedlogging
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogLevel 日志级别
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = "DEBUG"
|
||||
LogLevelInfo LogLevel = "INFO"
|
||||
LogLevelWarn LogLevel = "WARN"
|
||||
LogLevelError LogLevel = "ERROR"
|
||||
LogLevelFatal LogLevel = "FATAL"
|
||||
)
|
||||
|
||||
// LogEntry 统一 JSON 日志 schema — 三服务共享
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Service string `json:"service"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// Logger 输出 JSON 结构化日志 — 三服务共享实现
|
||||
type Logger struct {
|
||||
service string
|
||||
minLevel LogLevel
|
||||
output io.Writer
|
||||
exit func(int)
|
||||
}
|
||||
|
||||
// SensitiveFields 需要自动脱敏的字段关键字(三服务共享)
|
||||
var SensitiveFields = []string{
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"credential",
|
||||
"authorization",
|
||||
"private_key",
|
||||
"credit_card",
|
||||
"ssn",
|
||||
"passport", // supply-api 额外字段
|
||||
}
|
||||
|
||||
// NewLogger 创建统一 JSON logger
|
||||
func NewLogger(service string, minLevel LogLevel) *Logger {
|
||||
return &Logger{
|
||||
service: service,
|
||||
minLevel: minLevel,
|
||||
output: os.Stdout,
|
||||
exit: os.Exit,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLoggerWithOutput 创建带自定义输出的 logger(用于测试)
|
||||
func NewLoggerWithOutput(service string, minLevel LogLevel, output io.Writer) *Logger {
|
||||
return &Logger{
|
||||
service: service,
|
||||
minLevel: minLevel,
|
||||
output: output,
|
||||
exit: func(int) {},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) shouldLog(level LogLevel) bool {
|
||||
levels := map[LogLevel]int{
|
||||
LogLevelDebug: 0,
|
||||
LogLevelInfo: 1,
|
||||
LogLevelWarn: 2,
|
||||
LogLevelError: 3,
|
||||
LogLevelFatal: 4,
|
||||
}
|
||||
return levels[level] >= levels[l.minLevel]
|
||||
}
|
||||
|
||||
func (l *Logger) log(level LogLevel, msg string, fields map[string]interface{}) {
|
||||
if !l.shouldLog(level) {
|
||||
return
|
||||
}
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Level: string(level),
|
||||
Service: l.service,
|
||||
Message: msg,
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
entry.Fields = sanitizeFields(fields)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(l.output)
|
||||
_ = encoder.Encode(entry)
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LogLevelDebug, msg, firstFields(fields))
|
||||
}
|
||||
|
||||
func (l *Logger) Info(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LogLevelInfo, msg, firstFields(fields))
|
||||
}
|
||||
|
||||
func (l *Logger) Warn(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LogLevelWarn, msg, firstFields(fields))
|
||||
}
|
||||
|
||||
func (l *Logger) Error(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LogLevelError, msg, firstFields(fields))
|
||||
}
|
||||
|
||||
// Fatal 记录日志后调用 os.Exit(1)
|
||||
func (l *Logger) Fatal(msg string, fields ...map[string]interface{}) {
|
||||
l.log(LogLevelFatal, msg, firstFields(fields))
|
||||
if l.exit != nil {
|
||||
l.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Debugf(format string, args ...interface{}) {
|
||||
l.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, args ...interface{}) {
|
||||
l.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Warnf(format string, args ...interface{}) {
|
||||
l.Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, args ...interface{}) {
|
||||
l.Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Fatalf(format string, args ...interface{}) {
|
||||
l.Fatal(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func firstFields(fields []map[string]interface{}) map[string]interface{} {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fields[0]
|
||||
}
|
||||
|
||||
// sanitizeFields 敏感字段脱敏(三服务共享)
|
||||
func sanitizeFields(fields map[string]interface{}) map[string]interface{} {
|
||||
sanitized := make(map[string]interface{}, len(fields))
|
||||
for k, v := range fields {
|
||||
lowerKey := toLower(k)
|
||||
redacted := false
|
||||
for _, sensitive := range SensitiveFields {
|
||||
if contains(lowerKey, sensitive) {
|
||||
sanitized[k] = "[REDACTED]"
|
||||
redacted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if redacted {
|
||||
continue
|
||||
}
|
||||
if nestedMap, ok := v.(map[string]interface{}); ok {
|
||||
sanitized[k] = sanitizeFields(nestedMap)
|
||||
continue
|
||||
}
|
||||
sanitized[k] = v
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
result[i] = c
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
if len(substr) == 0 || len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
242
gateway/internal/shared/logging/logger_test.go
Normal file
242
gateway/internal/shared/logging/logger_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package sharedlogging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLogLevels_GoldenOutput 验证各日志级别的输出格式符合规范
|
||||
func TestLogLevels_GoldenOutput(t *testing.T) {
|
||||
testCases := []struct {
|
||||
level string
|
||||
logFunc func(*Logger, string)
|
||||
expectLevel string
|
||||
}{
|
||||
{"INFO", func(l *Logger, msg string) { l.Info(msg) }, "INFO"},
|
||||
{"WARN", func(l *Logger, msg string) { l.Warn(msg) }, "WARN"},
|
||||
{"ERROR", func(l *Logger, msg string) { l.Error(msg) }, "ERROR"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf)
|
||||
|
||||
tc.logFunc(logger, "test message")
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
if output == "" {
|
||||
t.Fatalf("empty output for level %s", tc.level)
|
||||
}
|
||||
|
||||
// 解析 JSON 验证格式
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(output), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\noutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Golden assertions: 固定字段
|
||||
if entry.Level != tc.expectLevel {
|
||||
t.Errorf("level = %q, want %q", entry.Level, tc.expectLevel)
|
||||
}
|
||||
if entry.Service != "test-service" {
|
||||
t.Errorf("service = %q, want %q", entry.Service, "test-service")
|
||||
}
|
||||
if entry.Message != "test message" {
|
||||
t.Errorf("message = %q, want %q", entry.Message, "test message")
|
||||
}
|
||||
if entry.Timestamp == "" {
|
||||
t.Error("timestamp should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogLevels_WithFields 验证带 fields 的日志输出
|
||||
func TestLogLevels_WithFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf)
|
||||
|
||||
logger.Info("request completed", map[string]interface{}{
|
||||
"request_id": "req-123",
|
||||
"status": 200,
|
||||
"duration": 45.6,
|
||||
})
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(output), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if entry.Level != "INFO" {
|
||||
t.Errorf("level = %q, want INFO", entry.Level)
|
||||
}
|
||||
if entry.Fields == nil {
|
||||
t.Fatal("fields should not be nil")
|
||||
}
|
||||
if entry.Fields["request_id"] != "req-123" {
|
||||
t.Errorf("fields[request_id] = %v, want req-123", entry.Fields["request_id"])
|
||||
}
|
||||
if entry.Fields["status"].(float64) != 200 {
|
||||
t.Errorf("fields[status] = %v, want 200", entry.Fields["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveFields_GoldenOutput 验证敏感字段被正确脱敏
|
||||
func TestSensitiveFields_GoldenOutput(t *testing.T) {
|
||||
sensitive := []struct {
|
||||
field string
|
||||
value interface{}
|
||||
}{
|
||||
{"password", "supersecret"},
|
||||
{"api_key", "sk-live-xxxxx"},
|
||||
{"authorization", "Bearer eyJ..."},
|
||||
{"token", "tok_xxxxx"},
|
||||
{"credit_card", "4111111111111111"},
|
||||
}
|
||||
|
||||
for _, tc := range sensitive {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf)
|
||||
|
||||
logger.Info("auth attempt", map[string]interface{}{
|
||||
tc.field: tc.value,
|
||||
"user": "alice",
|
||||
})
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(output), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if entry.Fields[tc.field] != "[REDACTED]" {
|
||||
t.Errorf("field %q = %v, want [REDACTED]", tc.field, entry.Fields[tc.field])
|
||||
}
|
||||
if entry.Fields["user"] != "alice" {
|
||||
t.Errorf("field user = %v, want alice", entry.Fields["user"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogLevelFiltering 验证日志级别过滤
|
||||
func TestLogLevelFiltering(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelError, &buf)
|
||||
|
||||
logger.Info("should not appear")
|
||||
logger.Warn("should not appear either")
|
||||
logger.Error("should appear")
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
if output == "" {
|
||||
t.Fatal("expected output for ERROR level")
|
||||
}
|
||||
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(output), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if entry.Level != "ERROR" {
|
||||
t.Errorf("level = %q, want ERROR", entry.Level)
|
||||
}
|
||||
if entry.Message != "should appear" {
|
||||
t.Errorf("message = %q, want 'should appear'", entry.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFatal_GoldenOutput 验证 FATAL 日志格式正确
|
||||
func TestFatal_GoldenOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf)
|
||||
var exited int
|
||||
logger.exit = func(code int) { exited = code }
|
||||
|
||||
logger.Fatal("service crashed", map[string]interface{}{"error": "OOM"})
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(output), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if entry.Level != "FATAL" {
|
||||
t.Errorf("level = %q, want FATAL", entry.Level)
|
||||
}
|
||||
if entry.Message != "service crashed" {
|
||||
t.Errorf("message = %q, want 'service crashed'", entry.Message)
|
||||
}
|
||||
if entry.Fields["error"] != "OOM" {
|
||||
t.Errorf("fields[error] = %v, want OOM", entry.Fields["error"])
|
||||
}
|
||||
if exited != 1 {
|
||||
t.Errorf("exit code = %d, want 1", exited)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestedFields_GoldenOutput 验证嵌套字段脱敏
|
||||
func TestNestedFields_GoldenOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf)
|
||||
|
||||
logger.Info("nested test", map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "alice",
|
||||
"password": "secret123",
|
||||
},
|
||||
})
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(output), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
nested, ok := entry.Fields["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("user field not a nested map")
|
||||
}
|
||||
if nested["password"] != "[REDACTED]" {
|
||||
t.Errorf("nested password = %v, want [REDACTED]", nested["password"])
|
||||
}
|
||||
if nested["name"] != "alice" {
|
||||
t.Errorf("nested name = %v, want alice", nested["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMethods 验证格式化方法(Infof/Errorf 等)
|
||||
func TestFormatMethods(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf)
|
||||
|
||||
logger.Infof("user %s logged in at %d", "alice", 1609459200)
|
||||
logger.Errorf("request to %s failed: %v", "/api/v1/test", "timeout")
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
var infoEntry, errorEntry LogEntry
|
||||
if err := json.Unmarshal([]byte(lines[0]), &infoEntry); err != nil {
|
||||
t.Fatalf("invalid INFO JSON: %v", err)
|
||||
}
|
||||
if infoEntry.Level != "INFO" {
|
||||
t.Errorf("info level = %q, want INFO", infoEntry.Level)
|
||||
}
|
||||
if infoEntry.Message != "user alice logged in at 1609459200" {
|
||||
t.Errorf("info message = %q, want 'user alice logged in at 1609459200'", infoEntry.Message)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(lines[1]), &errorEntry); err != nil {
|
||||
t.Fatalf("invalid ERROR JSON: %v", err)
|
||||
}
|
||||
if errorEntry.Level != "ERROR" {
|
||||
t.Errorf("error level = %q, want ERROR", errorEntry.Level)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user