修复bug及添加日志打印

This commit is contained in:
2026-07-28 10:36:32 +08:00
parent ea36e1155f
commit 8a48af6a3b
9 changed files with 1292 additions and 12 deletions

View File

@@ -0,0 +1,480 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"calllinesystem/server/internal/security"
)
const (
maxLoggedBody = 64 << 10
redactedLogText = "[REDACTED]"
)
var (
phoneInLogTextPattern = regexp.MustCompile(`(?:\+[0-9][0-9 ()-]{6,20}[0-9]|[0-9]{10,15})`)
importantLogRoutes = map[string]struct{}{
"POST /api/staff/auth/login": {},
"POST /api/staff/auth/logout": {},
"GET /api/staff/auth/me": {},
"POST /api/admin/auth/login": {},
"POST /api/admin/auth/logout": {},
"GET /api/admin/auth/me": {},
"GET /api/staff/projects": {},
"GET /api/staff/projects/{id}/queue": {},
"POST /api/staff/projects/{id}/tickets": {},
"POST /api/staff/projects/{id}/call-next": {},
"GET /api/public/status/{token}": {},
"GET /api/public/projects": {},
"POST /api/public/projects/{id}/tickets": {},
"POST /api/public/status/search": {},
"POST /api/internal/status/search": {},
"GET /api/display/{token}/snapshot": {},
"GET /api/events": {},
"GET /api/admin/overview": {},
"GET /api/admin/users": {},
"POST /api/admin/users": {},
"PUT /api/admin/users/{id}": {},
"POST /api/admin/projects": {},
"PUT /api/admin/projects/{id}": {},
"PUT /api/admin/projects/{id}/settings": {},
"GET /api/admin/history/tickets": {},
"GET /api/admin/history/tickets/{id}": {},
"GET /api/admin/history/batches": {},
"GET /api/admin/history/batches/{id}": {},
"GET /api/admin/history/summary": {},
"GET /api/admin/history/export.csv": {},
}
)
type logBodyCapture struct {
body bytes.Buffer
total int
truncated bool
}
func (capture *logBodyCapture) Write(value []byte) (int, error) {
capture.total += len(value)
remaining := maxLoggedBody - capture.body.Len()
if remaining <= 0 {
if len(value) > 0 {
capture.truncated = true
}
return len(value), nil
}
toCopy := min(remaining, len(value))
if toCopy > 0 {
_, _ = capture.body.Write(value[:toCopy])
}
if toCopy < len(value) {
capture.truncated = true
}
return len(value), nil
}
type logCaptureReadCloser struct {
io.ReadCloser
capture *logBodyCapture
}
func (reader *logCaptureReadCloser) Read(value []byte) (int, error) {
n, err := reader.ReadCloser.Read(value)
if n > 0 {
_, _ = reader.capture.Write(value[:n])
}
return n, err
}
func shouldCaptureLogBody(r *http.Request) bool {
if !strings.HasPrefix(r.URL.Path, "/api/") {
return false
}
return r.URL.Path != "/api/events" && r.URL.Path != "/api/admin/history/export.csv"
}
func isImportantLogRoute(r *http.Request) bool {
_, ok := importantLogRoutes[logRoutePattern(r)]
return ok
}
func logRoutePattern(r *http.Request) string {
if route := strings.TrimSpace(r.Pattern); route != "" {
return route
}
return r.Method + " " + r.URL.Path
}
func logPath(r *http.Request) string {
pattern := strings.TrimSpace(r.Pattern)
if pattern != "" {
if _, path, found := strings.Cut(pattern, " "); found {
return path
}
return pattern
}
switch {
case strings.HasPrefix(r.URL.Path, "/api/public/status/"):
return "/api/public/status/{token}"
case strings.HasPrefix(r.URL.Path, "/api/display/") && strings.HasSuffix(r.URL.Path, "/snapshot"):
return "/api/display/{token}/snapshot"
default:
return r.URL.Path
}
}
func requestLogDetails(r *http.Request, capture *logBodyCapture) map[string]any {
details := make(map[string]any)
pathParams := make(map[string]any)
if id := strings.TrimSpace(r.PathValue("id")); id != "" {
if validateUUID(id) == nil {
pathParams["id"] = id
} else {
pathParams["id"] = "[INVALID]"
}
}
if token := strings.TrimSpace(r.PathValue("token")); token != "" {
pathParams["token"] = redactedLogText
}
if len(pathParams) > 0 {
details["path_params"] = pathParams
}
if values := r.URL.Query(); len(values) > 0 {
query := make(map[string]any, len(values))
omitted := 0
for field, items := range values {
if !allowedLogQueryField(logRoutePattern(r), field) {
omitted += len(items)
continue
}
sanitized := make([]any, 0, len(items))
for _, item := range items {
sanitized = append(sanitized, sanitizeLogQueryValue(field, item))
}
query[field] = sanitized
}
if omitted > 0 {
query["_omitted_parameter_count"] = omitted
}
details["query"] = query
}
if strings.TrimSpace(r.Header.Get("Idempotency-Key")) != "" {
details["idempotency_key_present"] = true
}
appendCapturedLogBody(details, capture, r.Header.Get("Content-Type"), func(value any) (any, bool) {
return sanitizeRequestLogBody(r, capture.body.Bytes(), value)
})
if len(details) == 0 {
return nil
}
return details
}
func responseLogDetails(r *http.Request, recorder *statusRecorder) map[string]any {
if recorder.body == nil {
switch r.URL.Path {
case "/api/events":
return map[string]any{"body_omitted": "event_stream"}
case "/api/admin/history/export.csv":
return map[string]any{"body_omitted": "file_download"}
default:
return nil
}
}
details := make(map[string]any)
appendCapturedLogBody(details, recorder.body, recorder.Header().Get("Content-Type"), func(value any) (any, bool) {
return sanitizeLogValue("", value), true
})
if len(details) == 0 {
return nil
}
return details
}
func appendCapturedLogBody(
target map[string]any,
capture *logBodyCapture,
contentType string,
sanitize func(any) (any, bool),
) {
if capture == nil || capture.total == 0 {
return
}
target["body_bytes"] = capture.total
if contentType != "" {
target["content_type"] = contentType
}
if capture.truncated {
target["body_truncated"] = true
return
}
var value any
if err := json.Unmarshal(capture.body.Bytes(), &value); err != nil {
target["body_omitted"] = "invalid_or_non_json"
return
}
sanitized, ok := sanitize(value)
if !ok {
target["body_omitted"] = "invalid_or_unsupported_request_json"
return
}
target["body"] = sanitized
}
func sanitizeRequestLogBody(r *http.Request, raw []byte, value any) (any, bool) {
schema := requestLogSchema(logRoutePattern(r))
if schema == nil {
return nil, false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(schema); err != nil {
return nil, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, false
}
return sanitizeLogValue("", value), true
}
func requestLogSchema(route string) any {
switch route {
case "POST /api/staff/auth/login", "POST /api/admin/auth/login":
return &loginRequest{}
case "POST /api/staff/projects/{id}/tickets", "POST /api/public/projects/{id}/tickets":
return &createTicketRequest{}
case "POST /api/staff/projects/{id}/call-next":
return &callNextRequest{}
case "POST /api/public/status/search", "POST /api/internal/status/search":
return &publicPhoneQueryRequest{}
case "POST /api/admin/users", "PUT /api/admin/users/{id}":
return &adminUserRequest{}
case "POST /api/admin/projects", "PUT /api/admin/projects/{id}":
return &adminProjectRequest{}
case "PUT /api/admin/projects/{id}/settings":
return &updateProjectSettingsRequest{}
default:
return nil
}
}
func allowedLogQueryField(route, field string) bool {
switch route {
case "GET /api/events":
return field == "project_id"
case "GET /api/admin/history/tickets":
return isLogQueryField(field, "from", "to", "project_id", "status", "query", "page", "page_size")
case "GET /api/admin/history/tickets/{id}":
return field == "reveal"
case "GET /api/admin/history/batches":
return isLogQueryField(field, "from", "to", "project_id", "status", "query", "page", "page_size")
case "GET /api/admin/history/summary":
return isLogQueryField(field, "from", "to", "project_id", "status", "query")
case "GET /api/admin/history/export.csv":
return isLogQueryField(field, "from", "to", "project_id", "status", "query", "include_personal", "confirm")
default:
return false
}
}
func isLogQueryField(field string, allowed ...string) bool {
for _, candidate := range allowed {
if field == candidate {
return true
}
}
return false
}
func sanitizeLogQueryValue(field, value string) any {
switch field {
case "project_id":
if validateUUID(value) != nil {
return "[INVALID]"
}
return value
case "from", "to":
if _, err := time.Parse("2006-01-02", value); err != nil {
return "[INVALID]"
}
return value
case "status":
status := strings.ToUpper(strings.TrimSpace(value))
if !historyTicketStatuses[status] && !historyBatchStatuses[status] {
return "[INVALID]"
}
return status
case "page", "page_size":
number, err := strconv.Atoi(value)
if err != nil || number < 1 {
return "[INVALID]"
}
return number
case "include_personal", "confirm", "reveal":
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "1":
return true
case "false", "0":
return false
default:
return "[INVALID]"
}
case "query":
if normalized, err := security.NormalizePhone(value); err == nil {
return maskPhoneForLog(normalized)
}
if value == "" {
return ""
}
return "[PRESENT]"
default:
return "[OMITTED]"
}
}
func sanitizeLogValue(field string, value any) any {
normalizedField := normalizeLogField(field)
switch {
case isPhoneLogField(normalizedField):
if strings.Contains(normalizedField, "hmac") ||
strings.Contains(normalizedField, "cipher") ||
strings.Contains(normalizedField, "nonce") {
return redactedLogText
}
phone, ok := value.(string)
if !ok {
return redactedLogText
}
return maskPhoneForLog(phone)
case normalizedField == "last_name" ||
normalizedField == "surname" ||
normalizedField == "honorific" ||
normalizedField == "public_url" ||
normalizedField == "status_path":
return redactedLogText
case isUserIdentifierLogField(normalizedField):
identifier, ok := value.(string)
if !ok {
return redactedLogText
}
return maskIdentifierForLog(identifier)
case normalizedField == "query" ||
normalizedField == "search" ||
normalizedField == "q":
query, ok := value.(string)
if ok {
if normalized, err := security.NormalizePhone(query); err == nil {
return maskPhoneForLog(normalized)
}
}
case normalizedField == "visitor_notice":
return "[OMITTED]"
case isCredentialLogField(normalizedField):
return redactedLogText
}
switch typed := value.(type) {
case map[string]any:
sanitized := make(map[string]any, len(typed))
for childField, childValue := range typed {
sanitized[childField] = sanitizeLogValue(childField, childValue)
}
return sanitized
case []any:
sanitized := make([]any, 0, len(typed))
for _, childValue := range typed {
sanitized = append(sanitized, sanitizeLogValue(field, childValue))
}
return sanitized
case string:
return sanitizeLogString(field, typed)
default:
return value
}
}
func sanitizeLogString(field string, value string) string {
if strings.Contains(value, "/visitor/") ||
strings.Contains(value, "/api/public/status/") ||
strings.Contains(value, "/api/display/") {
return redactedLogText
}
return phoneInLogTextPattern.ReplaceAllStringFunc(value, maskPhoneForLog)
}
func normalizeLogField(field string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(field), "-", "_"))
}
func isPhoneLogField(field string) bool {
return strings.Contains(field, "phone") ||
strings.Contains(field, "mobile") ||
strings.Contains(field, "contact") ||
field == "tel" ||
strings.HasSuffix(field, "_tel")
}
func isCredentialLogField(field string) bool {
return strings.Contains(field, "password") ||
strings.Contains(field, "passwd") ||
strings.Contains(field, "token") ||
strings.Contains(field, "cookie") ||
strings.Contains(field, "authorization") ||
strings.Contains(field, "secret") ||
strings.Contains(field, "ciphertext") ||
strings.HasSuffix(field, "_nonce") ||
strings.Contains(field, "hmac") ||
strings.HasSuffix(field, "_hash") ||
strings.Contains(field, "digest") ||
field == "key" ||
strings.HasSuffix(field, "_key") ||
field == "pin" ||
strings.Contains(field, "otp") ||
strings.Contains(field, "captcha") ||
strings.Contains(field, "verification_code")
}
func isUserIdentifierLogField(field string) bool {
switch field {
case "username", "display_name", "created_by", "created_by_name", "requested_by", "requested_by_name":
return true
default:
return strings.HasSuffix(field, "_username")
}
}
func maskPhoneForLog(value string) string {
digits := make([]byte, 0, len(value))
for i := 0; i < len(value); i++ {
if value[i] >= '0' && value[i] <= '9' {
digits = append(digits, value[i])
}
}
if len(digits) < 4 {
return redactedLogText
}
return "****" + string(digits[len(digits)-4:])
}
func maskIdentifierForLog(value string) string {
runes := []rune(strings.TrimSpace(value))
switch len(runes) {
case 0:
return ""
case 1:
return "***"
case 2:
return string(runes[0]) + "***"
default:
return string(runes[0]) + "***" + string(runes[len(runes)-1])
}
}

View File

@@ -0,0 +1,133 @@
package httpapi
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"calllinesystem/server/internal/config"
"calllinesystem/server/internal/database"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
"github.com/google/uuid"
)
func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
if dsn == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
db, err := database.Open(ctx, dsn, logger)
if err != nil {
t.Fatal(err)
}
defer database.Close(db)
sqlDB, err := database.SQLDB(db)
if err != nil {
t.Fatal(err)
}
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
t.Fatal(err)
}
server, err := New(db, config.Config{
Environment: "development",
EncryptionKey: bytes.Repeat([]byte{0x41}, 32),
PhoneHMACKey: bytes.Repeat([]byte{0x42}, 32),
}, logger)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 28, 1, 0, 0, 0, time.UTC)
server.now = func() time.Time { return now }
var actor model.User
if err := db.Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil {
t.Fatal(err)
}
projectID := uuid.NewString()
project := model.Project{
ID: projectID, Code: strings.ToUpper("PUB" + uuid.NewString()[:6]), Name: "Public duplicate regression",
Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A",
CallBatchSize: 5, CallMode: model.CallModeBoth,
MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100,
MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5,
ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60,
ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60,
VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&project).Error; err != nil {
t.Fatal(err)
}
endedAt := now.Add(-12 * time.Hour)
oldSession := model.QueueSession{
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now.AddDate(0, 0, -1),
Status: "ENDED", NextTicketNumber: 2, Revision: 1,
OpenedAt: now.Add(-24 * time.Hour), ClosedAt: &endedAt, CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: endedAt,
}
currentSession := model.QueueSession{
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now,
Status: "RUNNING", NextTicketNumber: 1, Revision: 0,
OpenedAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&oldSession).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&currentSession).Error; err != nil {
t.Fatal(err)
}
phone := "13800138000"
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID))
if err != nil {
t.Fatal(err)
}
phoneHMAC := server.cipher.Digest(phone)
oldTicket := model.QueueTicket{
ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: oldSession.ID,
TicketNumber: 1, DisplayNumber: "00001", PartySize: 1,
PublicTokenHash: security.HashToken(uuid.NewString()),
PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC,
Honorific: "游客", Status: model.TicketWaiting, JoinedAt: oldSession.OpenedAt,
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actor.ID,
CreatedAt: oldSession.OpenedAt, UpdatedAt: oldSession.OpenedAt,
}
if err := db.Create(&oldTicket).Error; err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets",
strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`))
request.Header.Set("Idempotency-Key", "public-ended-session-"+uuid.NewString())
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", recorder.Code, recorder.Body.String())
}
duplicateRecorder := httptest.NewRecorder()
duplicateRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets",
strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`))
duplicateRequest.Header.Set("Idempotency-Key", "public-current-session-"+uuid.NewString())
server.Handler().ServeHTTP(duplicateRecorder, duplicateRequest)
if duplicateRecorder.Code != http.StatusConflict || !strings.Contains(duplicateRecorder.Body.String(), `"code":"DUPLICATE_PHONE"`) {
t.Fatalf("current-session duplicate status = %d, want 409 DUPLICATE_PHONE; body = %s",
duplicateRecorder.Code, duplicateRecorder.Body.String())
}
}

View File

@@ -104,7 +104,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/admin/history/batches/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatch)))
mux.Handle("GET /api/admin/history/summary", s.requireAdmin(http.HandlerFunc(s.adminHistorySummary)))
mux.Handle("GET /api/admin/history/export.csv", s.requireAdmin(http.HandlerFunc(s.adminHistoryExport)))
return s.recoverPanic(s.requestID(s.accessLog(s.securityHeaders(mux))))
return s.requestID(s.accessLog(s.recoverPanic(s.securityHeaders(mux))))
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
@@ -144,32 +144,84 @@ func (s *Server) requestID(next http.Handler) http.Handler {
type statusRecorder struct {
http.ResponseWriter
status int
status int
wroteHeader bool
body *logBodyCapture
}
func (r *statusRecorder) WriteHeader(status int) {
if r.wroteHeader {
return
}
r.status = status
r.wroteHeader = true
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
func (r *statusRecorder) Write(value []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
n, err := r.ResponseWriter.Write(value)
if n > 0 && r.body != nil {
_, _ = r.body.Write(value[:n])
}
return n, err
}
func (r *statusRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
func (r *statusRecorder) responseStarted() bool {
return r.wroteHeader
}
type flushStatusRecorder struct {
*statusRecorder
}
func (r *flushStatusRecorder) Flush() {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
r.ResponseWriter.(http.Flusher).Flush()
}
func (s *Server) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := s.now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
s.logger.Info("http request",
var requestBody *logBodyCapture
var responseBody *logBodyCapture
if shouldCaptureLogBody(r) {
requestBody = &logBodyCapture{}
responseBody = &logBodyCapture{}
if r.Body != nil {
r.Body = &logCaptureReadCloser{ReadCloser: r.Body, capture: requestBody}
}
}
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK, body: responseBody}
var responseWriter http.ResponseWriter = recorder
if _, ok := w.(http.Flusher); ok {
responseWriter = &flushStatusRecorder{statusRecorder: recorder}
}
next.ServeHTTP(responseWriter, r)
attributes := []any{
"request_id", requestID(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"path", logPath(r),
"status", recorder.status,
"duration_ms", time.Since(started).Milliseconds(),
)
}
if isImportantLogRoute(r) {
if request := requestLogDetails(r, requestBody); request != nil {
attributes = append(attributes, "request", request)
}
if response := responseLogDetails(r, recorder); response != nil {
attributes = append(attributes, "response", response)
}
}
s.logger.Info("http request", attributes...)
})
}
@@ -187,7 +239,19 @@ func (s *Server) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
s.logger.Error("panic recovered", "request_id", requestID(r.Context()), "error", recovered, "stack", string(debug.Stack()))
responseStarted := false
if state, ok := w.(interface{ responseStarted() bool }); ok {
responseStarted = state.responseStarted()
}
s.logger.Error("panic recovered",
"request_id", requestID(r.Context()),
"error", recovered,
"response_started", responseStarted,
"stack", string(debug.Stack()),
)
if responseStarted {
return
}
writeError(w, fmt.Errorf("panic: %v", recovered))
}
}()

View File

@@ -0,0 +1,553 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestAccessLogCapturesSanitizedJSONWithoutChangingExchange(t *testing.T) {
const (
phone = "13912345678"
lastName = "林"
publicToken = "public-token-sentinel"
idempotencyKey = "idempotency-key-sentinel"
projectID = "11111111-1111-4111-8111-111111111111"
username = "operator-sentinel"
uuidUsername = "22222222-2222-4222-8222-222222222222"
)
requestBody := `{"phone":"` + phone + `","last_name":"` + lastName +
`","honorific":"先生","party_size":2,"allow_duplicate":false}`
responseBody := `{"ticket":{"id":"ticket-1","phone":"` + phone + `","last_name":"` + lastName +
`","honorific":"先生","status":"WAITING"},"public_token":"` + publicToken +
`","public_url":"/visitor/` + publicToken + `","status_path":"/api/public/status/` + publicToken +
`","user":{"username":"` + username + `","display_name":"` + username +
`"},"history":{"created_by":"` + username + `","requested_by":"` + uuidUsername +
`","requested_by_name":"` + username + `"},"revision":7}`
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if string(got) != requestBody {
t.Fatalf("handler request body changed: got %q", got)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
if _, err := io.WriteString(w, responseBody); err != nil {
t.Fatalf("write response: %v", err)
}
})))
request := httptest.NewRequest(http.MethodPost,
"/api/public/projects/"+projectID+"/tickets",
strings.NewReader(requestBody))
request.Pattern = "POST /api/public/projects/{id}/tickets"
request.SetPathValue("id", projectID)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "request-123")
request.Header.Set("Idempotency-Key", idempotencyKey)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201", recorder.Code)
}
if recorder.Body.String() != responseBody {
t.Fatalf("response body changed: got %q", recorder.Body.String())
}
entry := logEntryByMessage(t, logs.Bytes(), "http request")
if entry["path"] != "/api/public/projects/{id}/tickets" {
t.Fatalf("logged path = %#v", entry["path"])
}
requestLog := mustLogObject(t, entry["request"], "request")
if requestLog["idempotency_key_present"] != true {
t.Fatalf("idempotency presence = %#v", requestLog["idempotency_key_present"])
}
pathParams := mustLogObject(t, requestLog["path_params"], "request.path_params")
if pathParams["id"] != projectID {
t.Fatalf("path id = %#v", pathParams["id"])
}
requestJSON := mustLogObject(t, requestLog["body"], "request.body")
if requestJSON["phone"] != "****5678" ||
requestJSON["last_name"] != "[REDACTED]" ||
requestJSON["honorific"] != "[REDACTED]" {
t.Fatalf("request body was not sanitized: %#v", requestJSON)
}
responseLog := mustLogObject(t, entry["response"], "response")
responseJSON := mustLogObject(t, responseLog["body"], "response.body")
ticket := mustLogObject(t, responseJSON["ticket"], "response.body.ticket")
if ticket["phone"] != "****5678" || ticket["last_name"] != "[REDACTED]" || ticket["honorific"] != "[REDACTED]" {
t.Fatalf("response ticket was not sanitized: %#v", ticket)
}
for _, field := range []string{"public_token", "public_url", "status_path"} {
if responseJSON[field] != "[REDACTED]" {
t.Fatalf("%s = %#v, want redacted", field, responseJSON[field])
}
}
user := mustLogObject(t, responseJSON["user"], "response.body.user")
history := mustLogObject(t, responseJSON["history"], "response.body.history")
if user["username"] != "o***l" || user["display_name"] != "o***l" ||
history["created_by"] != "o***l" || history["requested_by"] != "2***2" ||
history["requested_by_name"] != "o***l" {
t.Fatalf("user identifiers were not masked: user=%#v history=%#v", user, history)
}
logText := logs.String()
for _, secret := range []string{phone, lastName, publicToken, idempotencyKey, username, uuidUsername} {
if strings.Contains(logText, secret) {
t.Fatalf("log leaked secret %q: %s", secret, logText)
}
}
}
func TestAccessLogRedactsValidLoginCredentials(t *testing.T) {
const (
username = "operator-sentinel"
password = "password-sentinel"
)
requestBody := `{"username":"` + username + `","password":"` + password + `"}`
responseBody := `{"user":{"id":"user-1","username":"` + username + `","display_name":"` + username + `","role":"STAFF"}}`
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, err := io.ReadAll(r.Body)
if err != nil || string(got) != requestBody {
t.Fatalf("login body changed: %q, err=%v", got, err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, responseBody)
})))
request := httptest.NewRequest(http.MethodPost, "/api/staff/auth/login", strings.NewReader(requestBody))
request.Pattern = "POST /api/staff/auth/login"
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "login-request")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
entry := logEntryByMessage(t, logs.Bytes(), "http request")
requestJSON := mustLogObject(t, mustLogObject(t, entry["request"], "request")["body"], "request.body")
if requestJSON["username"] != "o***l" || requestJSON["password"] != "[REDACTED]" {
t.Fatalf("login request was not sanitized: %#v", requestJSON)
}
if strings.Contains(logs.String(), username) || strings.Contains(logs.String(), password) {
t.Fatalf("login credentials leaked into log: %s", logs.String())
}
}
func TestAccessLogOmitsUnknownOrInvalidRequestFields(t *testing.T) {
const (
password = "password-sentinel"
privateNote = "private-note-sentinel"
camelSurname = "林"
numericPhone = "13787654321"
)
requestBody := `{"username":"operator","password":"` + password + `","note":"` + privateNote +
`","lastName":"` + camelSurname + `","phone":` + numericPhone + `}`
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
writeError(w, &apiError{Status: http.StatusBadRequest, Code: "INVALID_JSON", Message: "请求内容格式不正确"})
})))
request := httptest.NewRequest(http.MethodPost, "/api/staff/auth/login", strings.NewReader(requestBody))
request.Pattern = "POST /api/staff/auth/login"
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "invalid-request")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
entry := logEntryByMessage(t, logs.Bytes(), "http request")
requestLog := mustLogObject(t, entry["request"], "request")
if requestLog["body_omitted"] != "invalid_or_unsupported_request_json" {
t.Fatalf("invalid body omission = %#v", requestLog)
}
if _, ok := requestLog["body"]; ok {
t.Fatalf("invalid request body was logged: %#v", requestLog)
}
for _, secret := range []string{password, privateNote, camelSurname, numericPhone} {
if strings.Contains(logs.String(), secret) {
t.Fatalf("invalid request leaked %q: %s", secret, logs.String())
}
}
}
func TestAccessLogOnlyLogsAllowedHistoryQueryParameters(t *testing.T) {
const (
phone = "+1 (202) 555-1234"
unknownSecret = "unknown-query-sentinel"
)
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"tickets": []any{}})
})))
request := httptest.NewRequest(http.MethodGet,
"/api/admin/history/tickets?from=2026-07-01&to=2026-07-28&query=%2B1+%28202%29+555-1234&page=2&unknown="+unknownSecret,
nil)
request.Pattern = "GET /api/admin/history/tickets"
request.Header.Set("X-Request-ID", "query-request")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
entry := logEntryByMessage(t, logs.Bytes(), "http request")
query := mustLogObject(t, mustLogObject(t, entry["request"], "request")["query"], "request.query")
if values := mustLogArray(t, query["query"], "request.query.query"); values[0] != "****1234" {
t.Fatalf("history phone query = %#v", values[0])
}
if query["_omitted_parameter_count"] != float64(1) {
t.Fatalf("omitted query count = %#v", query["_omitted_parameter_count"])
}
if strings.Contains(logs.String(), phone) || strings.Contains(logs.String(), unknownSecret) {
t.Fatalf("history query leaked into log: %s", logs.String())
}
}
func TestAccessLogTruncatesLargeBodiesWithoutChangingExchange(t *testing.T) {
requestBody := `{"visitor_notice":"REQUEST-LARGE-SENTINEL-` + strings.Repeat("x", 128<<10) + `"}`
responseBody := `{"note":"RESPONSE-LARGE-SENTINEL-` + strings.Repeat("y", 128<<10) + `"}`
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if string(got) != requestBody {
t.Fatalf("handler received %d bytes, want %d", len(got), len(requestBody))
}
w.Header().Set("Content-Type", "application/json")
if _, err := io.WriteString(w, responseBody); err != nil {
t.Fatalf("write response: %v", err)
}
})))
request := httptest.NewRequest(http.MethodPut, "/api/admin/projects/11111111-1111-4111-8111-111111111111/settings", strings.NewReader(requestBody))
request.Pattern = "PUT /api/admin/projects/{id}/settings"
request.SetPathValue("id", "11111111-1111-4111-8111-111111111111")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "request-large")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Body.String() != responseBody {
t.Fatalf("client received %d bytes, want %d", recorder.Body.Len(), len(responseBody))
}
entry := logEntryByMessage(t, logs.Bytes(), "http request")
requestLog := mustLogObject(t, entry["request"], "request")
responseLog := mustLogObject(t, entry["response"], "response")
if requestLog["body_truncated"] != true || responseLog["body_truncated"] != true {
t.Fatalf("truncation flags missing: request=%#v response=%#v", requestLog, responseLog)
}
if _, ok := requestLog["body"]; ok {
t.Fatalf("truncated request body should not be logged: %#v", requestLog)
}
if _, ok := responseLog["body"]; ok {
t.Fatalf("truncated response body should not be logged: %#v", responseLog)
}
if strings.Contains(logs.String(), "REQUEST-LARGE-SENTINEL") ||
strings.Contains(logs.String(), "RESPONSE-LARGE-SENTINEL") {
t.Fatalf("large body content leaked into log")
}
}
func TestStatusRecorderKeepsFirstWireStatus(t *testing.T) {
recorder := httptest.NewRecorder()
status := &statusRecorder{ResponseWriter: recorder, status: http.StatusOK}
status.WriteHeader(http.StatusCreated)
status.WriteHeader(http.StatusInternalServerError)
if status.status != http.StatusCreated {
t.Fatalf("recorded status = %d, want 201", status.status)
}
if recorder.Code != http.StatusCreated {
t.Fatalf("wire status = %d, want 201", recorder.Code)
}
}
func TestAccessLogDoesNotAdvertiseUnsupportedFlusher(t *testing.T) {
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, ok := w.(http.Flusher); ok {
t.Fatal("wrapped writer advertised unsupported http.Flusher")
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
})))
request := httptest.NewRequest(http.MethodGet, "/api/public/projects", nil)
request.Pattern = "GET /api/public/projects"
request.Header.Set("X-Request-ID", "writer-request")
recorder := &nonFlushingResponseWriter{header: make(http.Header)}
handler.ServeHTTP(recorder, request)
if recorder.status != http.StatusOK || recorder.body.String() != "{\"status\":\"ok\"}\n" {
t.Fatalf("wrapped response changed: status=%d body=%q", recorder.status, recorder.body.String())
}
}
func TestRecoveredPanicHasRequestIDAndAccessLog(t *testing.T) {
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
request.Header.Set("X-Request-ID", "panic-request")
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("panic status = %d, want 500", recorder.Code)
}
panicEntry := logEntryByMessage(t, logs.Bytes(), "panic recovered")
accessEntry := logEntryByMessage(t, logs.Bytes(), "http request")
if panicEntry["request_id"] != "panic-request" || accessEntry["request_id"] != "panic-request" {
t.Fatalf("request ids: panic=%#v access=%#v", panicEntry["request_id"], accessEntry["request_id"])
}
if accessEntry["status"] != float64(http.StatusInternalServerError) {
t.Fatalf("access status = %#v, want 500", accessEntry["status"])
}
}
func TestRecoveredPanicAfterWriteDoesNotAppendErrorBody(t *testing.T) {
const responseBody = `{"status":"started"}`
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(server.recoverPanic(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, responseBody)
panic("panic-after-write")
}))))
request := httptest.NewRequest(http.MethodGet, "/api/public/projects", nil)
request.Pattern = "GET /api/public/projects"
request.Header.Set("X-Request-ID", "panic-after-write")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != responseBody {
t.Fatalf("panic changed committed response: status=%d body=%q", recorder.Code, recorder.Body.String())
}
panicEntry := logEntryByMessage(t, logs.Bytes(), "panic recovered")
accessEntry := logEntryByMessage(t, logs.Bytes(), "http request")
if panicEntry["response_started"] != true || accessEntry["status"] != float64(http.StatusOK) {
t.Fatalf("panic/access metadata: panic=%#v access=%#v", panicEntry, accessEntry)
}
}
func TestHandlerLogsTokenRoutesUsingTemplates(t *testing.T) {
tests := []struct {
name string
path string
wantPath string
}{
{
name: "visitor status token",
path: "/api/public/status/visitor-token-sentinel",
wantPath: "/api/public/status/{token}",
},
{
name: "display token",
path: "/api/display/display-token-sentinel/snapshot",
wantPath: "/api/display/{token}/snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
request := httptest.NewRequest(http.MethodGet, test.path, nil)
request.Header.Set("X-Request-ID", "token-request")
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
entry := logEntryByMessage(t, logs.Bytes(), "http request")
if entry["path"] != test.wantPath {
t.Fatalf("path = %#v, want %q", entry["path"], test.wantPath)
}
if strings.Contains(logs.String(), "token-sentinel") {
t.Fatalf("token path leaked into log: %s", logs.String())
}
})
}
}
func TestAccessLogOmitsEventStreamAndCSVResponseBodies(t *testing.T) {
tests := []struct {
name string
method string
path string
pattern string
contentType string
body string
wantReason string
flush bool
}{
{
name: "event stream",
method: http.MethodGet,
path: "/api/events?project_id=project-1",
pattern: "GET /api/events",
contentType: "text/event-stream",
body: "data: STREAM-SENTINEL\n\n",
wantReason: "event_stream",
flush: true,
},
{
name: "personal CSV export",
method: http.MethodGet,
path: "/api/admin/history/export.csv?include_personal=true&confirm=true",
pattern: "GET /api/admin/history/export.csv",
contentType: "text/csv; charset=utf-8",
body: "phone,last_name\n13912345678,林\n",
wantReason: "file_download",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var logs bytes.Buffer
server := &Server{
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
now: time.Now,
}
handler := server.requestID(server.accessLog(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", test.contentType)
if _, err := io.WriteString(w, test.body); err != nil {
t.Fatalf("write response: %v", err)
}
if test.flush {
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer lost http.Flusher")
}
flusher.Flush()
}
})))
request := httptest.NewRequest(test.method, test.path, nil)
request.Pattern = test.pattern
request.Header.Set("X-Request-ID", "omit-request")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Body.String() != test.body {
t.Fatalf("response body changed: got %q", recorder.Body.String())
}
entry := logEntryByMessage(t, logs.Bytes(), "http request")
responseLog := mustLogObject(t, entry["response"], "response")
if responseLog["body_omitted"] != test.wantReason {
t.Fatalf("body omission = %#v, want %q", responseLog["body_omitted"], test.wantReason)
}
if strings.Contains(logs.String(), "STREAM-SENTINEL") ||
strings.Contains(logs.String(), "13912345678") ||
strings.Contains(logs.String(), "林") {
t.Fatalf("omitted response body leaked into log: %s", logs.String())
}
})
}
}
func logEntryByMessage(t *testing.T, content []byte, message string) map[string]any {
t.Helper()
for _, line := range bytes.Split(bytes.TrimSpace(content), []byte("\n")) {
var entry map[string]any
if err := json.Unmarshal(line, &entry); err != nil {
t.Fatalf("decode log entry: %v\n%s", err, line)
}
if entry["msg"] == message {
return entry
}
}
t.Fatalf("log message %q not found:\n%s", message, content)
return nil
}
func mustLogObject(t *testing.T, value any, name string) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("%s = %#v, want object", name, value)
}
return object
}
func mustLogArray(t *testing.T, value any, name string) []any {
t.Helper()
array, ok := value.([]any)
if !ok {
t.Fatalf("%s = %#v, want array", name, value)
}
return array
}
type nonFlushingResponseWriter struct {
header http.Header
body bytes.Buffer
status int
}
func (writer *nonFlushingResponseWriter) Header() http.Header {
return writer.header
}
func (writer *nonFlushingResponseWriter) WriteHeader(status int) {
if writer.status == 0 {
writer.status = status
}
}
func (writer *nonFlushingResponseWriter) Write(value []byte) (int, error) {
if writer.status == 0 {
writer.status = http.StatusOK
}
return writer.body.Write(value)
}

View File

@@ -298,7 +298,7 @@ func (s *Server) createTicketForActor(w http.ResponseWriter, r *http.Request, ac
phoneDigest := s.cipher.Digest(phone)
var duplicates []model.QueueTicket
if err := tx.Select("id", "display_number", "status").
Where("project_id = ? AND phone_hmac = ? AND status IN ?", projectID, phoneDigest,
Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status IN ?", projectID, session.ID, phoneDigest,
[]string{model.TicketWaiting, model.TicketCalled, model.TicketArrived}).
Order("joined_at ASC").Find(&duplicates).Error; err != nil {
return err