348 lines
14 KiB
Go
348 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
)
|
|
|
|
type fakeRegulationsProvider struct {
|
|
callers []extension.RegulationsCaller
|
|
webQueries []string
|
|
search extension.RegulationsSearchResult
|
|
evidence extension.RegulationEvidence
|
|
web extension.WebSearchResult
|
|
capture extension.WebCapture
|
|
err error
|
|
}
|
|
|
|
func (f *fakeRegulationsProvider) SearchRegulations(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) {
|
|
f.callers = append(f.callers, c)
|
|
return f.search, f.err
|
|
}
|
|
|
|
func (f *fakeRegulationsProvider) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) {
|
|
f.callers = append(f.callers, c)
|
|
return f.evidence, f.err
|
|
}
|
|
|
|
func (f *fakeRegulationsProvider) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) {
|
|
f.callers = append(f.callers, c)
|
|
f.webQueries = append(f.webQueries, req.Query)
|
|
return f.web, f.err
|
|
}
|
|
|
|
func (f *fakeRegulationsProvider) CaptureWeb(_ context.Context, c extension.RegulationsCaller, _ extension.WebCaptureRequest) (extension.WebCapture, error) {
|
|
f.callers = append(f.callers, c)
|
|
return f.capture, f.err
|
|
}
|
|
|
|
func newRegulationsTestHost(t *testing.T) (inprocHost, *store.Store) {
|
|
t.Helper()
|
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "regulations.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
if err := st.EnsureUser("alice", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
caps := hostcaps{st: st, regulations: ®ulationsSlot{}}
|
|
return inprocHost{caps: caps, pluginID: "rdm", owner: "alice", actor: "alice"}, st
|
|
}
|
|
|
|
func registerFakeRegulations(t *testing.T, h inprocHost) *fakeRegulationsProvider {
|
|
t.Helper()
|
|
p := &fakeRegulationsProvider{}
|
|
if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
const regV = extension.RegulationsSchemaVersion
|
|
|
|
func callAllRegulations(h inprocHost, version int) []error {
|
|
ctx := context.Background()
|
|
_, e1 := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: version, Query: "医疗器械 临床评价"})
|
|
_, e2 := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: version, CorpusRevision: "rev-1", NodeID: "n1"})
|
|
_, e3 := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: version, Query: "临床评价 指导原则"})
|
|
_, e4 := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: version, URL: "https://www.nmpa.gov.cn/x", SearchQuery: "临床评价 指导原则"})
|
|
return []error{e1, e2, e3, e4}
|
|
}
|
|
|
|
func TestRegulationsUnavailableWithoutProvider(t *testing.T) {
|
|
h, st := newRegulationsTestHost(t)
|
|
for i, err := range callAllRegulations(h, regV) {
|
|
if !errors.Is(err, extension.ErrRegulationsUnavailable) {
|
|
t.Fatalf("method %d without provider: %v", i, err)
|
|
}
|
|
}
|
|
h.caps.regulations = nil
|
|
for i, err := range callAllRegulations(h, regV) {
|
|
if !errors.Is(err, extension.ErrRegulationsUnavailable) {
|
|
t.Fatalf("method %d without slot: %v", i, err)
|
|
}
|
|
}
|
|
if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 {
|
|
t.Fatalf("unavailable calls audited: %+v %v", evs, err)
|
|
}
|
|
}
|
|
|
|
func TestRegulationsRegistrationOnlyTrustedKbregOnce(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
first, second := &fakeRegulationsProvider{}, &fakeRegulationsProvider{}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterRegulationsProvider(first); err == nil {
|
|
t.Fatal("non-kbreg plugin registered a regulations provider")
|
|
}
|
|
kbreg := pluginRegistry{pluginID: "kbreg", caps: h.caps}
|
|
if err := kbreg.RegisterRegulationsProvider(nil); err == nil {
|
|
t.Fatal("nil provider registered")
|
|
}
|
|
if err := kbreg.RegisterRegulationsProvider(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := kbreg.RegisterRegulationsProvider(second); err == nil {
|
|
t.Fatal("duplicate registration accepted")
|
|
}
|
|
if h.caps.regulations.get() != first {
|
|
t.Fatal("duplicate registration replaced the first provider")
|
|
}
|
|
if err := (pluginRegistry{pluginID: "kbreg"}).RegisterRegulationsProvider(first); err == nil {
|
|
t.Fatal("registration without a host slot accepted")
|
|
}
|
|
}
|
|
|
|
func TestRegulationsDeclarativeRegistrationRejected(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
p := &fakeRegulationsProvider{}
|
|
if err := (compiledRegistry{}).RegisterRegulationsProvider(p); err == nil {
|
|
t.Fatal("declarative registry accepted a regulations provider")
|
|
}
|
|
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "kbreg", caps: h.caps}}).RegisterRegulationsProvider(p); err != nil {
|
|
t.Fatalf("record registry did not forward kbreg registration: %v", err)
|
|
}
|
|
if h.caps.regulations.get() != p {
|
|
t.Fatal("forwarded registration did not fill the slot")
|
|
}
|
|
}
|
|
|
|
func TestRegulationsCallerFilledFromDispatchContext(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
h.actor = "alice-session"
|
|
p := registerFakeRegulations(t, h)
|
|
p.web.State = extension.WebSearchStateOK
|
|
for i, err := range callAllRegulations(h, regV) {
|
|
if err != nil {
|
|
t.Fatalf("method %d: %v", i, err)
|
|
}
|
|
}
|
|
want := extension.RegulationsCaller{PluginID: "rdm", Actor: "alice-session", Owner: "alice"}
|
|
if len(p.callers) != 4 {
|
|
t.Fatalf("provider calls = %d", len(p.callers))
|
|
}
|
|
for i, got := range p.callers {
|
|
if got != want {
|
|
t.Fatalf("call %d caller = %+v, want %+v", i, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegulationsRejectsOtherSchemaVersions(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
p := registerFakeRegulations(t, h)
|
|
for _, version := range []int{0, regV + 1} {
|
|
for i, err := range callAllRegulations(h, version) {
|
|
if !errors.Is(err, errRegulationsSchema) {
|
|
t.Fatalf("schema %d method %d: %v", version, i, err)
|
|
}
|
|
}
|
|
}
|
|
if len(p.callers) != 0 {
|
|
t.Fatalf("provider reached with unsupported schema: %d calls", len(p.callers))
|
|
}
|
|
}
|
|
|
|
func TestRegulationsWebQueryBounds(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
p := registerFakeRegulations(t, h)
|
|
ctx := context.Background()
|
|
for _, q := range []string{"", " \t\n", strings.Repeat("法", maxWebQueryRunes+1)} {
|
|
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: q}); !errors.Is(err, errWebQueryInvalid) {
|
|
t.Fatalf("query of %d runes accepted: %v", len([]rune(q)), err)
|
|
}
|
|
}
|
|
if len(p.callers) != 0 {
|
|
t.Fatal("provider reached with an invalid query")
|
|
}
|
|
maxQuery := strings.Repeat("法", maxWebQueryRunes)
|
|
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + maxQuery + " "}); err != nil {
|
|
t.Fatalf("256-rune query rejected: %v", err)
|
|
}
|
|
if len(p.webQueries) != 1 || p.webQueries[0] != maxQuery {
|
|
t.Fatalf("provider query was not trimmed: %q", p.webQueries)
|
|
}
|
|
}
|
|
|
|
func TestRegulationsExclusionRejectedBeforeProvider(t *testing.T) {
|
|
h, st := newRegulationsTestHost(t)
|
|
p := registerFakeRegulations(t, h)
|
|
ctx := context.Background()
|
|
query := "Project-Falcon 临床评价 指导原则"
|
|
res, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query,
|
|
Exclusions: []string{"", " ", "project-falcon"}})
|
|
if err != nil || res.State != extension.WebSearchStateRejected || res.SentQuery != "" || len(res.Results) != 0 {
|
|
t.Fatalf("excluded query result = %+v %v", res, err)
|
|
}
|
|
if len(p.callers) != 0 {
|
|
t.Fatal("provider received a query containing an excluded term")
|
|
}
|
|
evs, err := st.ListAuditEvents("alice", 10)
|
|
if err != nil || len(evs) != 1 || evs[0].Action != auditWebSearch {
|
|
t.Fatalf("rejection audit: %+v %v", evs, err)
|
|
}
|
|
if strings.Contains(strings.ToLower(evs[0].Evidence), "falcon") || !strings.Contains(evs[0].Evidence, `"state":"rejected"`) {
|
|
t.Fatalf("rejection audit evidence = %s", evs[0].Evidence)
|
|
}
|
|
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, Exclusions: []string{"", " "}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(p.callers) != 1 {
|
|
t.Fatal("blank exclusions blocked an allowed query")
|
|
}
|
|
}
|
|
|
|
func TestRegulationsProviderStatesPassThroughUnchanged(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
p := registerFakeRegulations(t, h)
|
|
at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC)
|
|
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-7", State: extension.RegulationsStateDegraded,
|
|
DegradedReasons: []string{"vector_index_unavailable"},
|
|
Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1",
|
|
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights"},
|
|
{RegulationID: "r2", EditionID: "e2", NodeID: "n2", Disclosure: extension.RegulationDisclosureMetadataOnly}}}
|
|
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-7", NodeID: "n1",
|
|
Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", HashVerified: false}
|
|
p.web = extension.WebSearchResult{Provider: "fake", SentQuery: "q", SearchedAt: at,
|
|
State: extension.WebSearchStateRateLimited, Reason: "quota"}
|
|
p.capture = extension.WebCapture{CaptureID: "c1", FetchedAt: at, Retention: extension.WebRetentionMetadataOnly,
|
|
State: extension.WebCaptureStateRejected, Reason: "robots"}
|
|
ctx := context.Background()
|
|
search, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"})
|
|
if err != nil || !reflect.DeepEqual(search, p.search) {
|
|
t.Fatalf("search = %+v %v", search, err)
|
|
}
|
|
evidence, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-7", NodeID: "n1"})
|
|
if err != nil || !reflect.DeepEqual(evidence, p.evidence) {
|
|
t.Fatalf("evidence = %+v %v", evidence, err)
|
|
}
|
|
web, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"})
|
|
if err != nil || !reflect.DeepEqual(web, p.web) {
|
|
t.Fatalf("web = %+v %v", web, err)
|
|
}
|
|
p.web.State = extension.WebSearchStateRejected
|
|
web, err = h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"})
|
|
if err != nil || web.State != extension.WebSearchStateRejected {
|
|
t.Fatalf("provider rejection rewritten: %+v %v", web, err)
|
|
}
|
|
capture, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org"})
|
|
if err != nil || !reflect.DeepEqual(capture, p.capture) {
|
|
t.Fatalf("capture = %+v %v", capture, err)
|
|
}
|
|
p.err = errors.New("provider failure")
|
|
if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}); !errors.Is(err, p.err) {
|
|
t.Fatalf("provider failure wrapped as success: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegulationsAuditEvidenceOmitsQueryAndText(t *testing.T) {
|
|
h, st := newRegulationsTestHost(t)
|
|
p := registerFakeRegulations(t, h)
|
|
const (
|
|
secretQuery = "第十七条 secret-query-text"
|
|
secretExcerpt = "SECRET-EXCERPT 原文片段"
|
|
secretPage = "SECRET-PAGE 网页全文"
|
|
)
|
|
p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK,
|
|
Hits: []extension.RegulationHit{{NodeID: "n1", Excerpt: secretExcerpt, Disclosure: extension.RegulationDisclosureExcerpt}}}
|
|
p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-9", NodeID: "n1", Excerpt: secretExcerpt,
|
|
Disclosure: extension.RegulationDisclosureFull, HashVerified: true}
|
|
p.web = extension.WebSearchResult{SentQuery: secretQuery, State: extension.WebSearchStateOK,
|
|
Results: []extension.WebSearchHit{{Rank: 1, URL: "https://example.org", Snippet: secretExcerpt}}}
|
|
p.capture = extension.WebCapture{State: extension.WebCaptureStateCaptured, Retention: extension.WebRetentionFull,
|
|
Text: secretPage, Candidates: []extension.WebClauseCandidate{{Excerpt: secretExcerpt}}}
|
|
ctx := context.Background()
|
|
if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: secretQuery}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-9", NodeID: "n1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + secretQuery + " "}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org", SearchQuery: secretQuery}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
evs, err := st.ListAuditEvents("alice", 10)
|
|
if err != nil || len(evs) != 4 {
|
|
t.Fatalf("audit events = %+v %v", evs, err)
|
|
}
|
|
sum := sha256.Sum256([]byte(secretQuery))
|
|
digest := hex.EncodeToString(sum[:])
|
|
want := map[string]regulationsAudit{
|
|
auditRegulationsSearch: {Revision: "rev-9", Count: 1, State: "ok", QuerySHA256: digest},
|
|
auditRegulationsEvidence: {Revision: "rev-9", Count: 1, State: "ok"},
|
|
auditWebSearch: {Count: 1, State: "ok", QuerySHA256: digest},
|
|
auditWebCapture: {Count: 1, State: "captured", QuerySHA256: digest},
|
|
}
|
|
for _, ev := range evs {
|
|
for _, secret := range []string{"secret-query-text", "第十七条", "SECRET-EXCERPT", "原文片段", "SECRET-PAGE", "网页全文"} {
|
|
if strings.Contains(ev.Evidence, secret) || strings.Contains(ev.Target, secret) {
|
|
t.Fatalf("%s audit leaks %q: %s", ev.Action, secret, ev.Evidence)
|
|
}
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal([]byte(ev.Evidence), &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k := range fields {
|
|
if k != "revision" && k != "count" && k != "state" && k != "querySha256" {
|
|
t.Fatalf("%s audit has unexpected field %q", ev.Action, k)
|
|
}
|
|
}
|
|
var got regulationsAudit
|
|
if err := json.Unmarshal([]byte(ev.Evidence), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w, ok := want[ev.Action]; !ok || got != w || ev.Owner != "alice" {
|
|
t.Fatalf("%s audit = %+v owner=%s, want %+v", ev.Action, got, ev.Owner, w)
|
|
}
|
|
delete(want, ev.Action)
|
|
}
|
|
}
|
|
|
|
func TestRegulationsRequireEffectiveAccount(t *testing.T) {
|
|
h, _ := newRegulationsTestHost(t)
|
|
p := registerFakeRegulations(t, h)
|
|
h.caps.accountGuard = func(string) error { return store.ErrAccountForbidden }
|
|
for i, err := range callAllRegulations(h, regV) {
|
|
if !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("method %d ignored the account guard: %v", i, err)
|
|
}
|
|
}
|
|
if len(p.callers) != 0 {
|
|
t.Fatal("provider reached for an ineffective account")
|
|
}
|
|
}
|