Compare commits

..

2 Commits

12 changed files with 1205 additions and 13 deletions
+3
View File
@@ -1,6 +1,9 @@
# DobotAI configuration
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
+32 -5
View File
@@ -12,6 +12,7 @@ import (
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
@@ -49,7 +50,15 @@ func main() {
if err != nil {
log.Fatal(err)
}
api, err := httpapi.New(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"))
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
}
auditOptions, err := buildAuditOptions(store, os.Getenv("DOBOTAI_TENANT_KEY"), os.Getenv("DOBOTAI_ADMIN_TOKEN"), address)
if err != nil {
log.Fatal(err)
}
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
if err != nil {
log.Fatal(err)
}
@@ -99,10 +108,6 @@ func main() {
defer streamClient.Close()
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
}
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
if address == "" {
address = "127.0.0.1:8090"
}
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
@@ -118,3 +123,25 @@ func main() {
log.Printf("HTTP shutdown: %v", err)
}
}
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
adminToken = strings.TrimSpace(adminToken)
if adminToken == "" {
return httpapi.Options{}, nil
}
if repo == nil {
return httpapi.Options{}, errors.New("audit repository is required when DOBOTAI_ADMIN_TOKEN is set")
}
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return httpapi.Options{}, errors.New("DOBOTAI_TENANT_KEY is required when DOBOTAI_ADMIN_TOKEN is set")
}
if err := httpapi.ValidateLoopbackListenAddress(address); err != nil {
return httpapi.Options{}, err
}
service, err := audit.NewService(tenantKey, repo)
if err != nil {
return httpapi.Options{}, err
}
return httpapi.Options{AuditService: service, AdminToken: adminToken}, nil
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
type auditRepoStub struct {
tenant string
}
func (r *auditRepoStub) ListTurns(_ context.Context, tenant string, _ audit.ListQuery) ([]audit.TurnSummary, error) {
r.tenant = tenant
return nil, nil
}
func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDetail, error) {
return audit.TurnDetail{}, audit.ErrNotFound
}
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
if err != nil {
t.Fatal(err)
}
if opts.AuditService != nil || opts.AdminToken != "" {
t.Fatalf("options=%+v", opts)
}
}
func TestBuildAuditOptionsRequiresTenantRepositoryAndLoopback(t *testing.T) {
repo := &auditRepoStub{}
for _, tc := range []struct {
name string
repo audit.TurnRepository
tenant string
address string
}{
{name: "missing repository", tenant: "tenant", address: "127.0.0.1:8090"},
{name: "missing tenant", repo: repo, address: "127.0.0.1:8090"},
{name: "public listener", repo: repo, tenant: "tenant", address: "0.0.0.0:8090"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildAuditOptions(tc.repo, tc.tenant, "admin-secret", tc.address); err == nil {
t.Fatal("expected configuration error")
}
})
}
}
func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
repo := &auditRepoStub{}
opts, err := buildAuditOptions(repo, " tenant-a ", " admin-secret ", "127.0.0.1:8090")
if err != nil {
t.Fatal(err)
}
if opts.AdminToken != "admin-secret" || opts.AuditService == nil {
t.Fatalf("options=%+v", opts)
}
if _, err := opts.AuditService.ListTurns(context.Background(), audit.ListTurnsRequest{}); err != nil {
t.Fatal(err)
}
if repo.tenant != "tenant-a" {
t.Fatalf("tenant=%q", repo.tenant)
}
}
+124
View File
@@ -0,0 +1,124 @@
package audit
import (
"context"
"errors"
"time"
)
var (
ErrNotFound = errors.New("turn not found")
ErrInvalidArgument = errors.New("invalid audit query")
)
// TurnStatus represents the status of a turn.
type TurnStatus string
const (
StatusProcessing TurnStatus = "processing"
StatusSucceeded TurnStatus = "succeeded"
StatusFailed TurnStatus = "failed"
StatusCancelled TurnStatus = "cancelled"
)
// TurnSummary is a lightweight representation of a turn for list views.
// It must never contain full question/answer text, error_message, WeKnora
// session/request IDs, or provider_message_id.
type TurnSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
QuestionSummary string `json:"question_summary"`
AnswerSummary string `json:"answer_summary"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
}
// SafeReference is a reference with only safe fields exposed.
// Content and Raw are never included.
type SafeReference struct {
ID string `json:"id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeTitle string `json:"knowledge_title"`
Score float64 `json:"score"`
}
// SafeMedia is a media audit entry with only safe metadata.
type SafeMedia struct {
Type string `json:"type"`
MIMEType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
Width int `json:"width"`
Height int `json:"height"`
}
// MessageMeta provides request/answer message metadata.
type MessageMeta struct {
ID string `json:"id"`
Direction string `json:"direction"`
Role string `json:"role"`
MessageType string `json:"message_type"`
Status string `json:"status"`
ActorName string `json:"actor_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
DeliveryMethod string `json:"delivery_method,omitempty"`
FailureCode string `json:"failure_code,omitempty"`
}
// TurnDetail is the full detail view of a turn. It must never include
// error_message, WeKnora session/request ID, provider_message_id,
// sessionWebhook, tokens, raw API/error, or Reference.Content/Raw.
type TurnDetail struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Question string `json:"question"`
Answer string `json:"answer"`
References []SafeReference `json:"references,omitempty"`
Media []SafeMedia `json:"media,omitempty"`
RequestMessage *MessageMeta `json:"request_message,omitempty"`
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
}
// ListTurnsRequest is the input for listing turns.
type ListTurnsRequest struct {
Limit int
Cursor string
Channel string
Status TurnStatus
}
// ListTurnsResult is the output of a list turns query.
type ListTurnsResult struct {
Items []TurnSummary `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ListQuery struct {
Limit int
Channel string
Status TurnStatus
BeforeCreatedAt *time.Time
BeforeID string
}
// TurnRepository is the data access interface for audit queries.
type TurnRepository interface {
ListTurns(ctx context.Context, tenantKey string, query ListQuery) ([]TurnSummary, error)
GetTurn(ctx context.Context, tenantKey, turnID string) (TurnDetail, error)
}
+54
View File
@@ -0,0 +1,54 @@
package audit
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
const maxCursorLength = 512
type cursorData struct {
CreatedAt time.Time `json:"t"`
ID string `json:"id"`
}
func encodeCursor(createdAt time.Time, id string) string {
data := cursorData{
CreatedAt: createdAt,
ID: id,
}
payload, err := json.Marshal(data)
if err != nil {
// This should never happen for a simple struct.
panic("encodeCursor marshal: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func encodeCursorString(payload string) string {
return base64.RawURLEncoding.EncodeToString([]byte(payload))
}
func decodeCursor(cursor string) (cursorData, error) {
if strings.TrimSpace(cursor) == "" {
return cursorData{}, errors.New("empty cursor")
}
if len(cursor) > maxCursorLength {
return cursorData{}, errors.New("cursor too long")
}
payload, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return cursorData{}, errors.New("invalid cursor encoding")
}
var data cursorData
if err := json.Unmarshal(payload, &data); err != nil {
return cursorData{}, errors.New("invalid cursor payload")
}
if data.CreatedAt.IsZero() || data.ID == "" {
return cursorData{}, errors.New("incomplete cursor data")
}
return data, nil
}
+63
View File
@@ -0,0 +1,63 @@
package audit
import (
"strings"
"testing"
"time"
)
func TestCursorRoundTrip(t *testing.T) {
now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
cursor := encodeCursor(now, "turn-abc-123")
decoded, err := decodeCursor(cursor)
if err != nil {
t.Fatalf("decodeCursor: %v", err)
}
if !decoded.CreatedAt.Equal(now) {
t.Fatalf("CreatedAt=%v, want %v", decoded.CreatedAt, now)
}
if decoded.ID != "turn-abc-123" {
t.Fatalf("ID=%q, want %q", decoded.ID, "turn-abc-123")
}
}
func TestDecodeCursorRejectsEmpty(t *testing.T) {
_, err := decodeCursor("")
if err == nil {
t.Fatal("expected error for empty cursor")
}
}
func TestDecodeCursorRejectsInvalidBase64(t *testing.T) {
_, err := decodeCursor("!!!not-valid-base64!!!")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecodeCursorRejectsTruncated(t *testing.T) {
// Create a valid cursor then truncate it
cursor := encodeCursor(time.Now().UTC(), "id")
truncated := cursor[:len(cursor)-3]
_, err := decodeCursor(truncated)
if err == nil {
t.Fatal("expected error for truncated cursor")
}
}
func TestDecodeCursorRejectsTooLong(t *testing.T) {
// 1024 bytes should exceed max cursor length
_, err := decodeCursor(strings.Repeat("A", 1024))
if err == nil {
t.Fatal("expected error for too-long cursor")
}
}
func TestDecodeCursorRejectsMissingFields(t *testing.T) {
// Invalid JSON payload (not a valid cursor struct)
// We create a cursor-like string that decodes but has missing fields
_, err := decodeCursor(encodeCursorString("{}"))
if err == nil {
t.Fatal("expected error for missing fields in cursor")
}
}
+97
View File
@@ -0,0 +1,97 @@
package audit
import (
"context"
"fmt"
"strings"
)
const (
defaultLimit = 20
maxLimit = 100
maxChannelLen = 128
maxTurnIDLen = 128
maxSummaryLen = 200
)
type Service struct {
tenantKey string
repo TurnRepository
}
func NewService(tenantKey string, repo TurnRepository) (*Service, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || repo == nil {
return nil, fmt.Errorf("%w: tenant and repository are required", ErrInvalidArgument)
}
return &Service{tenantKey: tenantKey, repo: repo}, nil
}
func (s *Service) ListTurns(ctx context.Context, req ListTurnsRequest) (ListTurnsResult, error) {
if req.Limit == 0 {
req.Limit = defaultLimit
}
if req.Limit < 1 || req.Limit > maxLimit {
return ListTurnsResult{}, fmt.Errorf("%w: limit must be between 1 and %d", ErrInvalidArgument, maxLimit)
}
req.Channel = strings.TrimSpace(req.Channel)
if len(req.Channel) > maxChannelLen {
return ListTurnsResult{}, fmt.Errorf("%w: channel too long", ErrInvalidArgument)
}
if !validStatus(req.Status) {
return ListTurnsResult{}, fmt.Errorf("%w: invalid status", ErrInvalidArgument)
}
query := ListQuery{Limit: req.Limit + 1, Channel: req.Channel, Status: req.Status}
if req.Cursor != "" {
cursor, err := decodeCursor(req.Cursor)
if err != nil {
return ListTurnsResult{}, fmt.Errorf("%w: invalid cursor", ErrInvalidArgument)
}
query.BeforeCreatedAt = &cursor.CreatedAt
query.BeforeID = cursor.ID
}
items, err := s.repo.ListTurns(ctx, s.tenantKey, query)
if err != nil {
return ListTurnsResult{}, err
}
for i := range items {
items[i].QuestionSummary = truncateRunes(items[i].QuestionSummary, maxSummaryLen)
items[i].AnswerSummary = truncateRunes(items[i].AnswerSummary, maxSummaryLen)
}
result := ListTurnsResult{Items: items}
if result.Items == nil {
result.Items = []TurnSummary{}
}
if len(result.Items) > req.Limit {
result.Items = result.Items[:req.Limit]
last := result.Items[len(result.Items)-1]
result.NextCursor = encodeCursor(last.CreatedAt, last.ID)
}
return result, nil
}
func (s *Service) GetTurn(ctx context.Context, turnID string) (TurnDetail, error) {
turnID = strings.TrimSpace(turnID)
if turnID == "" || len(turnID) > maxTurnIDLen {
return TurnDetail{}, fmt.Errorf("%w: invalid turn id", ErrInvalidArgument)
}
return s.repo.GetTurn(ctx, s.tenantKey, turnID)
}
func validStatus(status TurnStatus) bool {
switch status {
case "", StatusProcessing, StatusSucceeded, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
func truncateRunes(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
+179
View File
@@ -0,0 +1,179 @@
package audit
import (
"context"
"errors"
"strings"
"testing"
"time"
)
type stubRepo struct {
listFn func(context.Context, string, ListQuery) ([]TurnSummary, error)
getFn func(context.Context, string, string) (TurnDetail, error)
}
func (r *stubRepo) ListTurns(ctx context.Context, tenant string, query ListQuery) ([]TurnSummary, error) {
if r.listFn == nil {
return nil, nil
}
return r.listFn(ctx, tenant, query)
}
func (r *stubRepo) GetTurn(ctx context.Context, tenant, id string) (TurnDetail, error) {
if r.getFn == nil {
return TurnDetail{}, ErrNotFound
}
return r.getFn(ctx, tenant, id)
}
func TestNewServiceRequiresTenantAndRepository(t *testing.T) {
repo := &stubRepo{}
if _, err := NewService("", repo); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("empty tenant error=%v", err)
}
if _, err := NewService("tenant", nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("nil repository error=%v", err)
}
}
func TestServiceListUsesServerTenantDefaultLimitAndFetchesExtra(t *testing.T) {
var gotTenant string
var gotQuery ListQuery
repo := &stubRepo{listFn: func(_ context.Context, tenant string, query ListQuery) ([]TurnSummary, error) {
gotTenant, gotQuery = tenant, query
return []TurnSummary{}, nil
}}
svc, err := NewService(" tenant-a ", repo)
if err != nil {
t.Fatal(err)
}
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{})
if err != nil {
t.Fatal(err)
}
if gotTenant != "tenant-a" || gotQuery.Limit != 21 {
t.Fatalf("tenant=%q query=%+v", gotTenant, gotQuery)
}
if page.Items == nil || page.NextCursor != "" {
t.Fatalf("page=%+v", page)
}
}
func TestServiceListBuildsStableNextCursor(t *testing.T) {
t3 := time.Date(2026, 7, 21, 3, 0, 0, 0, time.UTC)
t2 := t3.Add(-time.Minute)
t1 := t2.Add(-time.Minute)
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.Limit != 3 {
t.Fatalf("repository limit=%d want=3", query.Limit)
}
return []TurnSummary{{ID: "turn-3", CreatedAt: t3}, {ID: "turn-2", CreatedAt: t2}, {ID: "turn-1", CreatedAt: t1}}, nil
}}
svc, _ := NewService("tenant", repo)
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Items[1].ID != "turn-2" || page.NextCursor == "" {
t.Fatalf("page=%+v", page)
}
decoded, err := decodeCursor(page.NextCursor)
if err != nil || decoded.ID != "turn-2" || !decoded.CreatedAt.Equal(t2) {
t.Fatalf("next cursor=%+v err=%v", decoded, err)
}
}
func TestServiceListDecodesCursorForRepository(t *testing.T) {
created := time.Date(2026, 7, 21, 2, 0, 0, 123, time.UTC)
cursor := encodeCursor(created, "turn-before")
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.BeforeID != "turn-before" || query.BeforeCreatedAt == nil || !query.BeforeCreatedAt.Equal(created) {
t.Fatalf("decoded query=%+v", query)
}
return nil, nil
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.ListTurns(context.Background(), ListTurnsRequest{Cursor: cursor}); err != nil {
t.Fatal(err)
}
}
func TestServiceListValidation(t *testing.T) {
svc, _ := NewService("tenant", &stubRepo{})
for _, tc := range []struct {
name string
req ListTurnsRequest
}{
{name: "negative limit", req: ListTurnsRequest{Limit: -1}},
{name: "too large limit", req: ListTurnsRequest{Limit: 101}},
{name: "bad status", req: ListTurnsRequest{Status: "bogus"}},
{name: "long channel", req: ListTurnsRequest{Channel: strings.Repeat("x", 129)}},
{name: "bad cursor", req: ListTurnsRequest{Cursor: "not-a-cursor"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.ListTurns(context.Background(), tc.req); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("error=%v", err)
}
})
}
}
func TestServiceListAcceptsMaximumLimitAndFilters(t *testing.T) {
repo := &stubRepo{listFn: func(_ context.Context, _ string, query ListQuery) ([]TurnSummary, error) {
if query.Limit != 101 || query.Channel != "dingtalk" || query.Status != StatusSucceeded {
t.Fatalf("query=%+v", query)
}
return nil, nil
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.ListTurns(context.Background(), ListTurnsRequest{Limit: 100, Channel: " dingtalk ", Status: StatusSucceeded}); err != nil {
t.Fatal(err)
}
}
func TestServiceListTruncatesUnicodeSummariesTo200Runes(t *testing.T) {
long := strings.Repeat("初", 250)
repo := &stubRepo{listFn: func(context.Context, string, ListQuery) ([]TurnSummary, error) {
return []TurnSummary{{ID: "turn", CreatedAt: time.Now(), QuestionSummary: long, AnswerSummary: long}}, nil
}}
svc, _ := NewService("tenant", repo)
page, err := svc.ListTurns(context.Background(), ListTurnsRequest{})
if err != nil {
t.Fatal(err)
}
if len([]rune(page.Items[0].QuestionSummary)) != 200 || len([]rune(page.Items[0].AnswerSummary)) != 200 {
t.Fatalf("summary lengths=%d/%d", len([]rune(page.Items[0].QuestionSummary)), len([]rune(page.Items[0].AnswerSummary)))
}
}
func TestServiceGetUsesServerTenant(t *testing.T) {
var gotTenant, gotID string
repo := &stubRepo{getFn: func(_ context.Context, tenant, id string) (TurnDetail, error) {
gotTenant, gotID = tenant, id
return TurnDetail{ID: id}, nil
}}
svc, _ := NewService("tenant-a", repo)
if _, err := svc.GetTurn(context.Background(), " turn-1 "); err != nil {
t.Fatal(err)
}
if gotTenant != "tenant-a" || gotID != "turn-1" {
t.Fatalf("tenant=%q id=%q", gotTenant, gotID)
}
}
func TestServiceGetValidationAndNotFound(t *testing.T) {
repo := &stubRepo{getFn: func(context.Context, string, string) (TurnDetail, error) {
return TurnDetail{}, ErrNotFound
}}
svc, _ := NewService("tenant", repo)
if _, err := svc.GetTurn(context.Background(), " "); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("blank id error=%v", err)
}
if _, err := svc.GetTurn(context.Background(), strings.Repeat("x", 129)); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("long id error=%v", err)
}
if _, err := svc.GetTurn(context.Background(), "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing error=%v", err)
}
}
+189
View File
@@ -0,0 +1,189 @@
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)
}
}
}
+155 -8
View File
@@ -5,10 +5,14 @@ import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
@@ -16,39 +20,88 @@ type QAService interface {
Ask(context.Context, core.QARequest) (core.QAResult, error)
}
type AuditService interface {
ListTurns(context.Context, audit.ListTurnsRequest) (audit.ListTurnsResult, error)
GetTurn(context.Context, string) (audit.TurnDetail, error)
}
type Options struct {
AuditService AuditService
AdminToken string
}
type Server struct {
service QAService
token string
mux *http.ServeMux
service QAService
ingressToken string
auditService AuditService
adminToken string
mux *http.ServeMux
}
func New(service QAService, ingressToken string) (*Server, error) {
return NewWithOptions(service, ingressToken, Options{})
}
func NewWithOptions(service QAService, ingressToken string, opts Options) (*Server, error) {
if service == nil {
return nil, errors.New("QA service is required")
}
if strings.TrimSpace(ingressToken) == "" {
return nil, errors.New("DOBOTAI_INGRESS_TOKEN is required")
}
s := &Server{service: service, token: ingressToken, mux: http.NewServeMux()}
adminToken := strings.TrimSpace(opts.AdminToken)
hasAuditService := opts.AuditService != nil
hasAdminToken := adminToken != ""
if hasAuditService != hasAdminToken {
return nil, errors.New("audit service and DOBOTAI_ADMIN_TOKEN must be configured together")
}
if hasAdminToken && constantTimeTokenEqual(adminToken, ingressToken) {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN must differ from DOBOTAI_INGRESS_TOKEN")
}
s := &Server{
service: service, ingressToken: ingressToken,
auditService: opts.AuditService, adminToken: adminToken,
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("POST /v1/qa", s.authorize(s.ask))
s.mux.HandleFunc("POST /v1/qa", s.authorizeToken(s.ingressToken, s.ask))
if hasAuditService {
s.mux.HandleFunc("GET /v1/admin/audit/turns", s.authorizeToken(s.adminToken, s.listAuditTurns))
s.mux.HandleFunc("GET /v1/admin/audit/turns/{id}", s.authorizeToken(s.adminToken, s.getAuditTurn))
}
return s, nil
}
func (s *Server) Handler() http.Handler { return s.mux }
func (s *Server) authorize(next http.HandlerFunc) http.HandlerFunc {
func (s *Server) authorizeToken(expected string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(provided) != len(s.token) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.token)) != 1 {
if strings.HasPrefix(r.URL.Path, "/v1/admin/audit/") {
w.Header().Set("Cache-Control", "no-store")
}
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
provided := strings.TrimPrefix(header, "Bearer ")
if provided == "" || strings.ContainsAny(provided, " \t\r\n") || !constantTimeTokenEqual(provided, expected) {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func constantTimeTokenEqual(provided, expected string) bool {
return len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
@@ -82,9 +135,103 @@ func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) listAuditTurns(w http.ResponseWriter, r *http.Request) {
allowed := map[string]bool{"limit": true, "cursor": true, "channel": true, "status": true}
for key, values := range r.URL.Query() {
if !allowed[key] || len(values) != 1 {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
}
var request audit.ListTurnsRequest
if raw, ok := singleQueryValue(r, "limit"); ok {
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 100 {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
request.Limit = limit
}
request.Cursor = r.URL.Query().Get("cursor")
request.Channel = r.URL.Query().Get("channel")
request.Status = audit.TurnStatus(r.URL.Query().Get("status"))
if len(request.Cursor) > 512 || len(request.Channel) > 128 || !validAuditStatus(request.Status) {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
result, err := s.auditService.ListTurns(r.Context(), request)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) getAuditTurn(w http.ResponseWriter, r *http.Request) {
if len(r.URL.Query()) != 0 {
writeError(w, http.StatusBadRequest, "invalid audit query")
return
}
result, err := s.auditService.GetTurn(r.Context(), r.PathValue("id"))
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func singleQueryValue(r *http.Request, key string) (string, bool) {
values, ok := r.URL.Query()[key]
if !ok || len(values) != 1 {
return "", false
}
return values[0], true
}
func validAuditStatus(status audit.TurnStatus) bool {
switch status {
case "", audit.StatusProcessing, audit.StatusSucceeded, audit.StatusFailed, audit.StatusCancelled:
return true
default:
return false
}
}
func writeAuditError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrInvalidArgument):
writeError(w, http.StatusBadRequest, "invalid audit query")
case errors.Is(err, audit.ErrNotFound):
writeError(w, http.StatusNotFound, "audit turn not found")
default:
writeError(w, http.StatusInternalServerError, "audit query failed")
}
}
func ValidateLoopbackListenAddress(address string) error {
host, port, err := net.SplitHostPort(strings.TrimSpace(address))
if err != nil || host == "" || port == "" {
return errors.New("audit API requires a loopback host and explicit port")
}
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 1 || portNumber > 65535 {
return errors.New("audit API requires a valid port")
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("audit API listen host %q is not loopback", host)
}
return nil
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+167
View File
@@ -0,0 +1,167 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const listAuditTurnsSQL = `SELECT
t.id,t.created_at,t.channel,t.status,
COALESCE(c.conversation_type,''),COALESCE(c.title,''),
COALESCE(a.external_actor_id,''),COALESCE(rm.actor_name_snapshot,''),
LEFT(t.question,200),LEFT(t.answer,200),
CASE WHEN jsonb_typeof(t.references_json)='array' THEN jsonb_array_length(t.references_json) ELSE 0 END,
COALESCE(am.status,''),COALESCE(am.delivery_method,'')
FROM turns t
LEFT JOIN messages rm ON rm.id=t.request_message_id AND rm.tenant_key=t.tenant_key AND rm.channel=t.channel
LEFT JOIN messages am ON am.id=t.answer_message_id AND am.tenant_key=t.tenant_key AND am.channel=t.channel
LEFT JOIN channel_conversations c ON c.id=rm.conversation_id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
LEFT JOIN channel_actors a ON a.id=rm.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
WHERE t.tenant_key=$1
AND ($2='' OR t.channel=$2)
AND ($3='' OR t.status=$3)
AND ($4::timestamptz IS NULL OR (t.created_at,t.id)<($4,$5))
ORDER BY t.created_at DESC,t.id DESC
LIMIT $6`
const getAuditTurnSQL = `SELECT
t.id,t.created_at,t.channel,t.status,
COALESCE(c.conversation_type,''),COALESCE(c.title,''),
COALESCE(a.external_actor_id,''),COALESCE(rm.actor_name_snapshot,''),
t.question,t.answer,t.references_json,t.media_json,
COALESCE(rm.id,''),COALESCE(rm.direction,''),COALESCE(rm.role,''),COALESCE(rm.message_type,''),COALESCE(rm.status,''),COALESCE(rm.actor_name_snapshot,''),rm.created_at,
COALESCE(am.id,''),COALESCE(am.direction,''),COALESCE(am.role,''),COALESCE(am.message_type,''),COALESCE(am.status,''),am.created_at,am.delivered_at,COALESCE(am.delivery_method,''),COALESCE(am.failure_code,'')
FROM turns t
LEFT JOIN messages rm ON rm.id=t.request_message_id AND rm.tenant_key=t.tenant_key AND rm.channel=t.channel
LEFT JOIN messages am ON am.id=t.answer_message_id AND am.tenant_key=t.tenant_key AND am.channel=t.channel
LEFT JOIN channel_conversations c ON c.id=rm.conversation_id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
LEFT JOIN channel_actors a ON a.id=rm.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
WHERE t.tenant_key=$1 AND t.id=$2`
func (s *Store) ListTurns(ctx context.Context, tenantKey string, query audit.ListQuery) ([]audit.TurnSummary, error) {
var before any
if query.BeforeCreatedAt != nil {
before = *query.BeforeCreatedAt
}
rows, err := s.pool.Query(ctx, listAuditTurnsSQL, tenantKey, query.Channel, string(query.Status), before, query.BeforeID, query.Limit)
if err != nil {
return nil, fmt.Errorf("list audit turns: %w", err)
}
defer rows.Close()
items := make([]audit.TurnSummary, 0)
for rows.Next() {
var item audit.TurnSummary
var status string
if err := rows.Scan(
&item.ID, &item.CreatedAt, &item.Channel, &status,
&item.ConversationType, &item.ConversationTitle,
&item.ActorExternalID, &item.ActorDisplayName,
&item.QuestionSummary, &item.AnswerSummary, &item.ReferenceCount,
&item.AnswerDeliveryStatus, &item.AnswerDeliveryMethod,
); err != nil {
return nil, fmt.Errorf("scan audit turn summary: %w", err)
}
item.Status = audit.TurnStatus(status)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate audit turns: %w", err)
}
return items, nil
}
func (s *Store) GetTurn(ctx context.Context, tenantKey, turnID string) (audit.TurnDetail, error) {
var detail audit.TurnDetail
var status string
var referencesJSON, mediaJSON []byte
var request audit.MessageMeta
var answer audit.MessageMeta
var requestCreated, answerCreated, deliveredAt *time.Time
err := s.pool.QueryRow(ctx, getAuditTurnSQL, tenantKey, turnID).Scan(
&detail.ID, &detail.CreatedAt, &detail.Channel, &status,
&detail.ConversationType, &detail.ConversationTitle,
&detail.ActorExternalID, &detail.ActorDisplayName,
&detail.Question, &detail.Answer, &referencesJSON, &mediaJSON,
&request.ID, &request.Direction, &request.Role, &request.MessageType, &request.Status, &request.ActorName, &requestCreated,
&answer.ID, &answer.Direction, &answer.Role, &answer.MessageType, &answer.Status, &answerCreated, &deliveredAt, &answer.DeliveryMethod, &answer.FailureCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return audit.TurnDetail{}, audit.ErrNotFound
}
if err != nil {
return audit.TurnDetail{}, fmt.Errorf("get audit turn: %w", err)
}
detail.Status = audit.TurnStatus(status)
var references []core.Reference
if len(referencesJSON) > 0 {
if err := json.Unmarshal(referencesJSON, &references); err != nil {
return audit.TurnDetail{}, fmt.Errorf("decode audit references: %w", err)
}
}
detail.References = safeAuditReferences(references)
detail.ReferenceCount = len(detail.References)
var media []core.MediaAudit
if len(mediaJSON) > 0 {
if err := json.Unmarshal(mediaJSON, &media); err != nil {
return audit.TurnDetail{}, fmt.Errorf("decode audit media: %w", err)
}
}
detail.Media = safeAuditMedia(media)
if request.ID != "" {
if requestCreated != nil {
request.CreatedAt = *requestCreated
}
detail.RequestMessage = &request
}
if answer.ID != "" {
if answerCreated != nil {
answer.CreatedAt = *answerCreated
}
answer.DeliveredAt = deliveredAt
detail.AnswerMessage = &answer
detail.AnswerDeliveryStatus = answer.Status
detail.AnswerDeliveryMethod = answer.DeliveryMethod
}
return detail, nil
}
func safeAuditReferences(references []core.Reference) []audit.SafeReference {
if len(references) == 0 {
return nil
}
safe := make([]audit.SafeReference, 0, len(references))
for _, reference := range references {
safe = append(safe, audit.SafeReference{
ID: reference.ID, KnowledgeID: reference.KnowledgeID,
KnowledgeTitle: reference.KnowledgeTitle, Score: reference.Score,
})
}
return safe
}
func safeAuditMedia(media []core.MediaAudit) []audit.SafeMedia {
if len(media) == 0 {
return nil
}
safe := make([]audit.SafeMedia, 0, len(media))
for _, item := range media {
safe = append(safe, audit.SafeMedia{
Type: item.Type, MIMEType: item.MIMEType, SizeBytes: item.SizeBytes,
Width: item.Width, Height: item.Height,
})
}
return safe
}
var _ audit.TurnRepository = (*Store)(nil)
@@ -0,0 +1,75 @@
package postgres
import (
"encoding/json"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func TestAuditListSQLTenantSafeAndKeyset(t *testing.T) {
sql := strings.ToUpper(listAuditTurnsSQL)
for _, want := range []string{
"FROM TURNS T", "T.TENANT_KEY=$1", "RM.TENANT_KEY=T.TENANT_KEY", "RM.CHANNEL=T.CHANNEL",
"AM.TENANT_KEY=T.TENANT_KEY", "AM.CHANNEL=T.CHANNEL",
"C.TENANT_KEY=T.TENANT_KEY", "C.CHANNEL=T.CHANNEL",
"A.TENANT_KEY=T.TENANT_KEY", "A.CHANNEL=T.CHANNEL",
"(T.CREATED_AT,T.ID)<($4,$5)", "ORDER BY T.CREATED_AT DESC,T.ID DESC", "LIMIT $6",
} {
if !strings.Contains(sql, want) {
t.Errorf("list audit SQL missing %q", want)
}
}
}
func TestAuditListSQLHandlesHistoricalJSONNullReferences(t *testing.T) {
sql := strings.ToUpper(listAuditTurnsSQL)
if !strings.Contains(sql, "CASE WHEN JSONB_TYPEOF(T.REFERENCES_JSON)='ARRAY'") ||
!strings.Contains(sql, "THEN JSONB_ARRAY_LENGTH(T.REFERENCES_JSON) ELSE 0 END") {
t.Fatal("list audit SQL must not call jsonb_array_length on historical JSON null/scalars")
}
}
func TestAuditDetailSQLTenantSafeAndExcludesForbiddenData(t *testing.T) {
sql := strings.ToUpper(getAuditTurnSQL)
for _, want := range []string{
"T.TENANT_KEY=$1", "T.ID=$2", "RM.TENANT_KEY=T.TENANT_KEY", "AM.TENANT_KEY=T.TENANT_KEY",
"C.TENANT_KEY=T.TENANT_KEY", "A.TENANT_KEY=T.TENANT_KEY",
} {
if !strings.Contains(sql, want) {
t.Errorf("detail audit SQL missing %q", want)
}
}
for _, forbidden := range []string{"ERROR_MESSAGE", "WEKNORA_SESSION_ID", "WEKNORA_REQUEST_ID", "PROVIDER_MESSAGE_ID", "SAFE_METADATA_JSON"} {
if strings.Contains(sql, forbidden) {
t.Errorf("detail audit SQL must not select forbidden %q", forbidden)
}
}
}
func TestSafeAuditReferencesDropContentAndRaw(t *testing.T) {
references := []core.Reference{{
ID: "ref-1", KnowledgeID: "kb-1", KnowledgeTitle: "Manual", Score: 0.9,
Content: "private chunk body", Raw: json.RawMessage(`{"private":"payload"}`),
}}
safe := safeAuditReferences(references)
if len(safe) != 1 || safe[0].ID != "ref-1" || safe[0].KnowledgeID != "kb-1" || safe[0].KnowledgeTitle != "Manual" || safe[0].Score != 0.9 {
t.Fatalf("safe references=%+v", safe)
}
raw, err := json.Marshal(safe)
if err != nil {
t.Fatal(err)
}
body := string(raw)
if strings.Contains(body, "private chunk") || strings.Contains(body, "payload") || strings.Contains(strings.ToLower(body), "content") || strings.Contains(strings.ToLower(body), "raw") {
t.Fatalf("safe references leaked forbidden data: %s", body)
}
}
func TestSafeAuditMediaCopiesMetadataOnly(t *testing.T) {
media := safeAuditMedia([]core.MediaAudit{{Type: "image", MIMEType: "image/png", SizeBytes: 42, Width: 10, Height: 20}})
if len(media) != 1 || media[0].Type != "image" || media[0].MIMEType != "image/png" || media[0].SizeBytes != 42 || media[0].Width != 10 || media[0].Height != 20 {
t.Fatalf("safe media=%+v", media)
}
}