250 lines
10 KiB
Go
250 lines
10 KiB
Go
package securityaudit
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
|
|
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fakePromptAdminService struct {
|
||
|
|
config PublicConfig
|
||
|
|
save func(context.Context, UpdateConfigRequest, int64) (PublicConfig, error)
|
||
|
|
probe func(context.Context, ProbeRequest) ProbeResult
|
||
|
|
runtime RuntimeSnapshot
|
||
|
|
list func(context.Context, EventFilter, int, int) (*EventPage, error)
|
||
|
|
get func(context.Context, int64) (*Event, error)
|
||
|
|
deleteOne func(context.Context, int64) (*DeleteResult, error)
|
||
|
|
deleteIDs func(context.Context, []int64) (*DeleteResult, error)
|
||
|
|
preview func(context.Context, EventFilter, int64) (*DeletePreview, error)
|
||
|
|
deleteFilter func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *fakePromptAdminService) GetConfig() (PublicConfig, error) {
|
||
|
|
return s.config, nil
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
||
|
|
if s.save == nil {
|
||
|
|
return PublicConfig{}, errors.New("unexpected SaveConfig call")
|
||
|
|
}
|
||
|
|
return s.save(ctx, req, actorID)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) Probe(ctx context.Context, req ProbeRequest) ProbeResult {
|
||
|
|
if s.probe == nil {
|
||
|
|
return ProbeResult{}
|
||
|
|
}
|
||
|
|
return s.probe(ctx, req)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) Runtime(context.Context) RuntimeSnapshot { return s.runtime }
|
||
|
|
func (s *fakePromptAdminService) ListEvents(ctx context.Context, filter EventFilter, page, pageSize int) (*EventPage, error) {
|
||
|
|
if s.list == nil {
|
||
|
|
return &EventPage{}, nil
|
||
|
|
}
|
||
|
|
return s.list(ctx, filter, page, pageSize)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) GetEvent(ctx context.Context, id int64) (*Event, error) {
|
||
|
|
if s.get == nil {
|
||
|
|
return nil, ErrEventNotFound
|
||
|
|
}
|
||
|
|
return s.get(ctx, id)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) DeleteEvent(ctx context.Context, id int64) (*DeleteResult, error) {
|
||
|
|
if s.deleteOne == nil {
|
||
|
|
return &DeleteResult{}, nil
|
||
|
|
}
|
||
|
|
return s.deleteOne(ctx, id)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) DeleteEventsByIDs(ctx context.Context, ids []int64) (*DeleteResult, error) {
|
||
|
|
if s.deleteIDs == nil {
|
||
|
|
return &DeleteResult{}, nil
|
||
|
|
}
|
||
|
|
return s.deleteIDs(ctx, ids)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) PreviewDelete(ctx context.Context, filter EventFilter, actorID int64) (*DeletePreview, error) {
|
||
|
|
if s.preview == nil {
|
||
|
|
return &DeletePreview{}, nil
|
||
|
|
}
|
||
|
|
return s.preview(ctx, filter, actorID)
|
||
|
|
}
|
||
|
|
func (s *fakePromptAdminService) DeleteByFilter(ctx context.Context, req DeleteByFilterRequest, actorID int64) (*DeleteResult, error) {
|
||
|
|
if s.deleteFilter == nil {
|
||
|
|
return &DeleteResult{}, nil
|
||
|
|
}
|
||
|
|
return s.deleteFilter(ctx, req, actorID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func promptAdminRouter(service PromptAdminService) *gin.Engine {
|
||
|
|
gin.SetMode(gin.TestMode)
|
||
|
|
router := gin.New()
|
||
|
|
router.Use(func(c *gin.Context) {
|
||
|
|
c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 42})
|
||
|
|
c.Set(string(servermiddleware.ContextKeyUserRole), "admin")
|
||
|
|
c.Next()
|
||
|
|
})
|
||
|
|
handler := NewPromptAdminHandler(service)
|
||
|
|
group := router.Group("/admin/prompt-audit")
|
||
|
|
group.GET("/config", handler.GetConfig)
|
||
|
|
group.PUT("/config", handler.UpdateConfig)
|
||
|
|
group.POST("/endpoints/probe", handler.ProbeEndpoint)
|
||
|
|
group.GET("/runtime", handler.GetRuntime)
|
||
|
|
group.GET("/events", handler.ListEvents)
|
||
|
|
group.GET("/events/:id", handler.GetEvent)
|
||
|
|
group.DELETE("/events/:id", handler.DeleteEvent)
|
||
|
|
group.POST("/events/batch-delete", handler.BatchDelete)
|
||
|
|
group.POST("/events/delete-preview", handler.DeletePreview)
|
||
|
|
group.POST("/events/delete-by-filter", handler.DeleteByFilter)
|
||
|
|
return router
|
||
|
|
}
|
||
|
|
|
||
|
|
func promptAdminRequest(t *testing.T, router http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||
|
|
t.Helper()
|
||
|
|
var reader *bytes.Reader
|
||
|
|
if body == nil {
|
||
|
|
reader = bytes.NewReader(nil)
|
||
|
|
} else {
|
||
|
|
raw, err := json.Marshal(body)
|
||
|
|
require.NoError(t, err)
|
||
|
|
reader = bytes.NewReader(raw)
|
||
|
|
}
|
||
|
|
req := httptest.NewRequest(method, path, reader)
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
recorder := httptest.NewRecorder()
|
||
|
|
router.ServeHTTP(recorder, req)
|
||
|
|
return recorder
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *testing.T) {
|
||
|
|
const canary = "prompt-admin-token-canary"
|
||
|
|
|
||
|
|
t.Run("missing expected version", func(t *testing.T) {
|
||
|
|
router := promptAdminRouter(&fakePromptAdminService{})
|
||
|
|
response := promptAdminRequest(t, router, http.MethodPut, "/admin/prompt-audit/config", map[string]any{})
|
||
|
|
require.Equal(t, http.StatusBadRequest, response.Code)
|
||
|
|
require.Contains(t, response.Body.String(), "prompt_audit_invalid_config_request")
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("CAS conflict", func(t *testing.T) {
|
||
|
|
service := &fakePromptAdminService{save: func(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) {
|
||
|
|
return PublicConfig{}, infraerrors.Conflict(ErrorCodeConfigConflict, "配置已被更新")
|
||
|
|
}}
|
||
|
|
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodPut, "/admin/prompt-audit/config", validHandlerUpdateRequest(canary))
|
||
|
|
require.Equal(t, http.StatusConflict, response.Code)
|
||
|
|
require.Contains(t, response.Body.String(), ErrorCodeConfigConflict)
|
||
|
|
require.NotContains(t, response.Body.String(), canary)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("success public DTO", func(t *testing.T) {
|
||
|
|
service := &fakePromptAdminService{save: func(_ context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
||
|
|
require.Equal(t, int64(42), actorID)
|
||
|
|
require.Equal(t, canary, req.Endpoints[0].Token)
|
||
|
|
return PublicConfig{ConfigVersion: 8, Endpoints: []PublicEndpoint{{ID: "guard-1", HasToken: true, TokenStatus: "configured"}}}, nil
|
||
|
|
}}
|
||
|
|
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodPut, "/admin/prompt-audit/config", validHandlerUpdateRequest(canary))
|
||
|
|
require.Equal(t, http.StatusOK, response.Code)
|
||
|
|
body := response.Body.String()
|
||
|
|
require.NotContains(t, body, canary)
|
||
|
|
require.NotContains(t, body, "token_ciphertext")
|
||
|
|
require.NotContains(t, body, `"token":`)
|
||
|
|
require.Contains(t, body, `"has_token":true`)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) {
|
||
|
|
const canary = "persisted-config-secret-canary"
|
||
|
|
repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)}
|
||
|
|
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||
|
|
require.Error(t, manager.Reload(context.Background()))
|
||
|
|
service := &PromptService{config: manager}
|
||
|
|
|
||
|
|
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodGet, "/admin/prompt-audit/config", nil)
|
||
|
|
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||
|
|
require.Contains(t, response.Body.String(), ErrorCodeConfigUnavailable)
|
||
|
|
require.NotContains(t, response.Body.String(), canary)
|
||
|
|
require.NotContains(t, response.Body.String(), `"config_version"`)
|
||
|
|
require.NotContains(t, response.Body.String(), `"token"`)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPromptAdminProbeSupportsTemporaryOrSavedTokenWithoutEcho(t *testing.T) {
|
||
|
|
const canary = "probe-token-canary"
|
||
|
|
for _, tc := range []struct {
|
||
|
|
name string
|
||
|
|
token string
|
||
|
|
tokenApplied bool
|
||
|
|
}{
|
||
|
|
{name: "temporary token", token: canary, tokenApplied: true},
|
||
|
|
{name: "saved token", token: "", tokenApplied: true},
|
||
|
|
} {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
service := &fakePromptAdminService{probe: func(_ context.Context, req ProbeRequest) ProbeResult {
|
||
|
|
require.Equal(t, tc.token, req.Endpoint.Token)
|
||
|
|
return ProbeResult{OK: true, Status: "healthy", Message: "ok", TokenApplied: tc.tokenApplied}
|
||
|
|
}}
|
||
|
|
endpoint := validHandlerUpdateRequest(tc.token).Endpoints[0]
|
||
|
|
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodPost, "/admin/prompt-audit/endpoints/probe", ProbeRequest{Endpoint: endpoint})
|
||
|
|
require.Equal(t, http.StatusOK, response.Code)
|
||
|
|
require.NotContains(t, response.Body.String(), canary)
|
||
|
|
require.NotContains(t, response.Body.String(), `"token":`)
|
||
|
|
require.Contains(t, response.Body.String(), `"token_applied":true`)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPromptAdminRejectsInvalidEventIDsTimesAndPagination(t *testing.T) {
|
||
|
|
router := promptAdminRouter(&fakePromptAdminService{})
|
||
|
|
for _, tc := range []struct {
|
||
|
|
method string
|
||
|
|
path string
|
||
|
|
body any
|
||
|
|
reason string
|
||
|
|
}{
|
||
|
|
{http.MethodGet, "/admin/prompt-audit/events/not-a-number", nil, "prompt_audit_invalid_event_id"},
|
||
|
|
{http.MethodDelete, "/admin/prompt-audit/events/-1", nil, "prompt_audit_invalid_event_id"},
|
||
|
|
{http.MethodGet, "/admin/prompt-audit/events?group_id=bad", nil, "prompt_audit_invalid_filter_id"},
|
||
|
|
{http.MethodGet, "/admin/prompt-audit/events?start_at=not-time", nil, "prompt_audit_invalid_time"},
|
||
|
|
{http.MethodGet, "/admin/prompt-audit/events?page=0", nil, "prompt_audit_invalid_pagination"},
|
||
|
|
{http.MethodPost, "/admin/prompt-audit/events/batch-delete", map[string]any{"ids": []int64{1, -2}}, "prompt_audit_invalid_event_id"},
|
||
|
|
} {
|
||
|
|
response := promptAdminRequest(t, router, tc.method, tc.path, tc.body)
|
||
|
|
require.Equalf(t, http.StatusBadRequest, response.Code, "%s %s", tc.method, tc.path)
|
||
|
|
require.Contains(t, response.Body.String(), tc.reason)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func validHandlerUpdateRequest(token string) UpdateConfigRequest {
|
||
|
|
return UpdateConfigRequest{
|
||
|
|
ExpectedConfigVersion: 7,
|
||
|
|
Strategy: "priority",
|
||
|
|
WorkerCount: 1,
|
||
|
|
QueueCapacity: 10,
|
||
|
|
Scanners: []string{"pii"},
|
||
|
|
AllGroups: true,
|
||
|
|
Endpoints: []UpdateEndpoint{{
|
||
|
|
ID: "guard-1", Name: "Guard One", Protocol: "openai_compatible",
|
||
|
|
BaseURL: "http://127.0.0.1:18080", Model: DefaultGuardModel, Token: token,
|
||
|
|
TimeoutMS: 1000, InputLimit: 1024, Enabled: true,
|
||
|
|
}},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPromptAdminDeleteConfirmationErrorsStayGeneric(t *testing.T) {
|
||
|
|
service := &fakePromptAdminService{deleteFilter: func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error) {
|
||
|
|
return nil, errors.New("sensitive-token-or-filter-detail")
|
||
|
|
}}
|
||
|
|
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodPost, "/admin/prompt-audit/events/delete-by-filter", DeleteByFilterRequest{
|
||
|
|
SnapshotMaxID: 3, FilterHash: strings.Repeat("a", 64), ConfirmationToken: "secret-confirmation", Confirm: true,
|
||
|
|
})
|
||
|
|
require.Equal(t, http.StatusBadRequest, response.Code)
|
||
|
|
require.Contains(t, response.Body.String(), "prompt_audit_delete_confirmation_invalid")
|
||
|
|
require.NotContains(t, response.Body.String(), "sensitive-token")
|
||
|
|
require.NotContains(t, response.Body.String(), "secret-confirmation")
|
||
|
|
}
|