190 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|