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) } } }