Files
dobotai-platform/internal/httpapi/audit_server_test.go
2026-07-21 13:57:31 +08:00

190 lines
8.1 KiB
Go

package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
type fakeAuditService struct {
listRequest audit.ListTurnsRequest
listResult audit.ListTurnsResult
listErr error
getID string
getResult audit.TurnDetail
getErr error
}
func (f *fakeAuditService) ListTurns(_ context.Context, request audit.ListTurnsRequest) (audit.ListTurnsResult, error) {
f.listRequest = request
return f.listResult, f.listErr
}
func (f *fakeAuditService) GetTurn(_ context.Context, id string) (audit.TurnDetail, error) {
f.getID = id
return f.getResult, f.getErr
}
func TestAuditOptionsRequireCompleteDistinctConfiguration(t *testing.T) {
for _, tc := range []struct {
name string
opts Options
}{
{name: "service only", opts: Options{AuditService: &fakeAuditService{}}},
{name: "token only", opts: Options{AdminToken: "admin-secret"}},
{name: "same ingress and admin token", opts: Options{AuditService: &fakeAuditService{}, AdminToken: "ingress-secret"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := NewWithOptions(fakeQA{}, "ingress-secret", tc.opts); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestAuditRoutesAreAbsentWhenDisabled(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
t.Fatal(err)
}
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil))
if result.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
}
func TestAuditListRequiresIndependentAdminBearer(t *testing.T) {
service := &fakeAuditService{listResult: audit.ListTurnsResult{Items: []audit.TurnSummary{}}}
server, err := NewWithOptions(fakeQA{}, "ingress-secret", Options{AuditService: service, AdminToken: "admin-secret"})
if err != nil {
t.Fatal(err)
}
for _, authorization := range []string{"", "Bearer wrong", "Bearer ingress-secret", "bearer admin-secret", "Bearer admin-secret"} {
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil)
request.Header.Set("Authorization", authorization)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusUnauthorized || !strings.Contains(result.Body.String(), "unauthorized") {
t.Fatalf("auth=%q status=%d body=%s", authorization, result.Code, result.Body.String())
}
}
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil)
request.Header.Set("Authorization", "Bearer admin-secret")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK || result.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status=%d cache=%q body=%s", result.Code, result.Header().Get("Cache-Control"), result.Body.String())
}
}
func TestAuditListParsesAllowedQueryOnly(t *testing.T) {
service := &fakeAuditService{listResult: audit.ListTurnsResult{Items: []audit.TurnSummary{}}}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns?limit=100&cursor=opaque&channel=dingtalk&status=succeeded", nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
if service.listRequest.Limit != 100 || service.listRequest.Cursor != "opaque" || service.listRequest.Channel != "dingtalk" || service.listRequest.Status != audit.StatusSucceeded {
t.Fatalf("request=%+v", service.listRequest)
}
for _, query := range []string{"tenant_key=other", "unknown=x", "limit=0", "limit=101", "limit=x", "limit=1&limit=2"} {
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns?"+query, nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusBadRequest {
t.Fatalf("query=%q status=%d body=%s", query, result.Code, result.Body.String())
}
}
}
func TestAuditListMapsServiceErrorsWithoutLeaking(t *testing.T) {
service := &fakeAuditService{listErr: errors.New("postgres password=private unavailable")}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns", nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusInternalServerError || strings.Contains(result.Body.String(), "password") || strings.Contains(result.Body.String(), "private") {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
service.listErr = audit.ErrInvalidArgument
result = httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusBadRequest {
t.Fatalf("invalid query status=%d", result.Code)
}
}
func TestAuditDetailReturnsSafeFullQuestionAndAnswer(t *testing.T) {
now := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)
service := &fakeAuditService{getResult: audit.TurnDetail{
ID: "turn-1", CreatedAt: now, Channel: "dingtalk", Status: audit.StatusSucceeded,
Question: "full question", Answer: "full answer",
References: []audit.SafeReference{{ID: "ref-1", KnowledgeTitle: "Manual", Score: 0.8}},
AnswerMessage: &audit.MessageMeta{ID: "answer-1", Direction: "outbound", Role: "assistant", MessageType: "text", Status: "sent", CreatedAt: now, DeliveryMethod: "card"},
}}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
request := httptest.NewRequest(http.MethodGet, "/v1/admin/audit/turns/turn-1", nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
body := result.Body.String()
if result.Code != http.StatusOK || service.getID != "turn-1" || !strings.Contains(body, "full question") || !strings.Contains(body, "full answer") {
t.Fatalf("status=%d id=%q body=%s", result.Code, service.getID, body)
}
for _, forbidden := range []string{"weknora_session", "weknora_request", "provider_message", "sessionWebhook", "token", "raw_error", "error_message", "content"} {
if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) {
t.Fatalf("detail leaked forbidden %q: %s", forbidden, body)
}
}
}
func TestAuditDetailMapsNotFoundInvalidAndInternal(t *testing.T) {
service := &fakeAuditService{getErr: audit.ErrNotFound}
server, _ := NewWithOptions(fakeQA{}, "ingress", Options{AuditService: service, AdminToken: "admin"})
call := func(path string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer admin")
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
return result
}
if got := call("/v1/admin/audit/turns/missing"); got.Code != http.StatusNotFound {
t.Fatalf("not found status=%d", got.Code)
}
service.getErr = audit.ErrInvalidArgument
if got := call("/v1/admin/audit/turns/bad"); got.Code != http.StatusBadRequest {
t.Fatalf("invalid status=%d", got.Code)
}
service.getErr = errors.New("raw database secret")
if got := call("/v1/admin/audit/turns/turn"); got.Code != http.StatusInternalServerError || strings.Contains(got.Body.String(), "secret") {
t.Fatalf("internal status=%d body=%s", got.Code, got.Body.String())
}
}
func TestValidateLoopbackListenAddress(t *testing.T) {
for _, address := range []string{"127.0.0.1:8090", "localhost:8090", "[::1]:8090"} {
if err := ValidateLoopbackListenAddress(address); err != nil {
t.Errorf("address=%q error=%v", address, err)
}
}
for _, address := range []string{"0.0.0.0:8090", "192.168.1.5:8090", ":8090", "127.0.0.1", "example.com:8090"} {
if err := ValidateLoopbackListenAddress(address); err == nil {
t.Errorf("address=%q should fail", address)
}
}
}