CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
247 lines
7.6 KiB
Go
247 lines
7.6 KiB
Go
package searchapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testlock"
|
|
)
|
|
|
|
const responseTimeLimit = 500 * time.Millisecond
|
|
|
|
type denyList map[string]bool
|
|
|
|
func (d denyList) CanAccessDocument(_ context.Context, _, documentID string) (bool, error) {
|
|
return !d[documentID], nil
|
|
}
|
|
|
|
func setup(t *testing.T, perms PermissionChecker) (*httptest.Server, *sql.DB) {
|
|
t.Helper()
|
|
addr := os.Getenv("TEST_MANTICORE_ADDR")
|
|
if addr == "" {
|
|
t.Skip("TEST_MANTICORE_ADDR nicht gesetzt, Manticore-Integrationstest uebersprungen")
|
|
}
|
|
testlock.Acquire(t, "manticore")
|
|
db, err := searchindex.Open(addr)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := searchindex.EnsureSchema(context.Background(), db); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
NewServer(db, perms).RegisterRoutes(mux)
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return srv, db
|
|
}
|
|
|
|
var idSeq = time.Now().UnixNano() & 0x3FFFFFFFFFFFFFFF
|
|
|
|
func index(t *testing.T, db *sql.DB, doc searchindex.Doc) searchindex.Doc {
|
|
t.Helper()
|
|
idSeq++
|
|
doc.ID = idSeq
|
|
if doc.DocumentID == "" {
|
|
doc.DocumentID = fmt.Sprintf("doc-%d", doc.ID)
|
|
}
|
|
if doc.UpdatedAtSec == 0 {
|
|
doc.UpdatedAtSec = time.Now().Unix()
|
|
}
|
|
doc.CreatedAtSec = doc.UpdatedAtSec
|
|
if err := searchindex.Upsert(context.Background(), db, doc); err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = searchindex.Delete(context.Background(), db, doc.ID) })
|
|
return doc
|
|
}
|
|
|
|
func token(t *testing.T) string {
|
|
return fmt.Sprintf("zq%dx", time.Now().UnixNano())
|
|
}
|
|
|
|
func get(t *testing.T, url string) (int, responseJSON) {
|
|
t.Helper()
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var out responseJSON
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
return resp.StatusCode, out
|
|
}
|
|
|
|
// AK 1 + Pruefung 1: Fundstellen im Ausschnitt, Titel vor Tags vor Volltext.
|
|
func TestSearch_RankingAndSnippets(t *testing.T) {
|
|
srv, db := setup(t, denyList{})
|
|
tok := token(t)
|
|
body := index(t, db, searchindex.Doc{Title: "Allgemein", Body: strings.Repeat("fuellwort ", 30) + tok + " steht im Text", CreatedBy: "u1"})
|
|
title := index(t, db, searchindex.Doc{Title: tok + " Vertrag", Body: "anderer inhalt", CreatedBy: "u1"})
|
|
tags := index(t, db, searchindex.Doc{Title: "Sonstiges", Body: "anderer inhalt", Tags: tok, CreatedBy: "u1"})
|
|
|
|
status, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
|
|
if status != 200 {
|
|
t.Fatalf("status = %d", status)
|
|
}
|
|
var order []string
|
|
for _, h := range out.Hits {
|
|
order = append(order, h.DocumentID)
|
|
}
|
|
want := []string{title.DocumentID, tags.DocumentID, body.DocumentID}
|
|
if strings.Join(order, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("reihenfolge = %v, want %v", order, want)
|
|
}
|
|
if out.Total != 3 {
|
|
t.Errorf("total = %d, want 3", out.Total)
|
|
}
|
|
for _, h := range out.Hits {
|
|
if h.DocumentID == body.DocumentID && !strings.Contains(h.SnippetHTML, "<mark>"+tok+"</mark>") {
|
|
t.Errorf("fundstelle nicht markiert: %q", h.SnippetHTML)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearch_SnippetEscapesHTMLFromDocumentText(t *testing.T) {
|
|
srv, db := setup(t, denyList{})
|
|
tok := token(t)
|
|
index(t, db, searchindex.Doc{Title: "x", Body: "<script>alert(1)</script> " + tok, CreatedBy: "u1"})
|
|
|
|
_, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
|
|
if len(out.Hits) != 1 {
|
|
t.Fatalf("hits = %d", len(out.Hits))
|
|
}
|
|
if strings.Contains(out.Hits[0].SnippetHTML, "<script>") {
|
|
t.Errorf("unescaptes html im snippet: %q", out.Hits[0].SnippetHTML)
|
|
}
|
|
}
|
|
|
|
// AK 2 + Pruefung 2: Negativtest - fremde und vom Checker verweigerte Dokumente erscheinen nicht.
|
|
func TestSearch_OnlyPermittedDocumentsAppear(t *testing.T) {
|
|
tok := token(t)
|
|
denied := "doc-denied-" + tok
|
|
srv, db := setup(t, denyList{denied: true})
|
|
own := index(t, db, searchindex.Doc{Title: tok + " eigen", CreatedBy: "u1"})
|
|
index(t, db, searchindex.Doc{Title: tok + " fremd", CreatedBy: "u2"})
|
|
index(t, db, searchindex.Doc{DocumentID: denied, Title: tok + " verweigert", CreatedBy: "u1"})
|
|
|
|
_, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
|
|
if len(out.Hits) != 1 || out.Hits[0].DocumentID != own.DocumentID {
|
|
t.Fatalf("hits = %+v, want nur %s", out.Hits, own.DocumentID)
|
|
}
|
|
}
|
|
|
|
func TestSearch_PaginationAndSorting(t *testing.T) {
|
|
srv, db := setup(t, denyList{})
|
|
tok := token(t)
|
|
now := time.Now().Unix()
|
|
var ids []string
|
|
for i := 0; i < 5; i++ {
|
|
d := index(t, db, searchindex.Doc{Title: tok, CreatedBy: "u1", UpdatedAtSec: now - int64(i*100)})
|
|
ids = append(ids, d.DocumentID)
|
|
}
|
|
|
|
_, p1 := get(t, srv.URL+"/search?caller=u1&sort=updated&limit=2&offset=0&q="+tok)
|
|
_, p2 := get(t, srv.URL+"/search?caller=u1&sort=updated&limit=2&offset=2&q="+tok)
|
|
_, p3 := get(t, srv.URL+"/search?caller=u1&sort=updated&limit=2&offset=4&q="+tok)
|
|
if p1.Total != 5 || p2.Total != 5 {
|
|
t.Errorf("total = %d/%d, want 5", p1.Total, p2.Total)
|
|
}
|
|
var got []string
|
|
for _, p := range []responseJSON{p1, p2, p3} {
|
|
for _, h := range p.Hits {
|
|
got = append(got, h.DocumentID)
|
|
}
|
|
}
|
|
if strings.Join(got, ",") != strings.Join(ids, ",") {
|
|
t.Errorf("reihenfolge ueber seiten = %v, want %v", got, ids)
|
|
}
|
|
if !sort.SliceIsSorted(p1.Hits, func(i, j int) bool { return p1.Hits[i].UpdatedAt > p1.Hits[j].UpdatedAt }) {
|
|
t.Error("seite 1 nicht nach updated absteigend")
|
|
}
|
|
}
|
|
|
|
func TestSearch_InvalidParametersReturn400(t *testing.T) {
|
|
srv, _ := setup(t, denyList{})
|
|
for _, q := range []string{
|
|
"caller=u1", "q=x", "caller=u1&q=x&sort=title", "caller=u1&q=x&limit=abc",
|
|
"caller=u1&q=x&limit=101", "caller=u1&q=x&offset=-1", "caller=u1&q=x&limit=100&offset=950",
|
|
} {
|
|
resp, err := http.Get(srv.URL + "/search?" + q)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("%q: status = %d, want 400", q, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AK 3 + Pruefung 3: gleichzeitige Suchanfragen, p95 unter dem Grenzwert.
|
|
func requireSerialPerf(t *testing.T) {
|
|
t.Helper()
|
|
if os.Getenv("TEST_PERF") != "1" {
|
|
t.Skip("Latenzmessung nur im seriellen Lauf (TEST_PERF=1 go test -p 1, Gate qa02-foundation-gate.sh): parallele Paketlaeufe verfaelschen sie")
|
|
}
|
|
}
|
|
|
|
func TestSearch_ConcurrentRequestsStayUnderResponseTimeLimit(t *testing.T) {
|
|
requireSerialPerf(t)
|
|
srv, db := setup(t, denyList{})
|
|
tok := token(t)
|
|
for i := 0; i < 200; i++ {
|
|
index(t, db, searchindex.Doc{Title: fmt.Sprintf("%s dokument %d", tok, i), Body: strings.Repeat("vertrag kuendigung ", 50) + tok, CreatedBy: "u1"})
|
|
}
|
|
|
|
for i := 0; i < 20; i++ {
|
|
get(t, srv.URL+"/search?caller=u1&q="+tok)
|
|
}
|
|
|
|
const workers, perWorker = 20, 10
|
|
var mu sync.Mutex
|
|
var durations []time.Duration
|
|
var wg sync.WaitGroup
|
|
for w := 0; w < workers; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < perWorker; i++ {
|
|
start := time.Now()
|
|
status, out := get(t, srv.URL+"/search?caller=u1&q="+tok)
|
|
d := time.Since(start)
|
|
if status != 200 || len(out.Hits) == 0 {
|
|
t.Errorf("status = %d, hits = %d", status, len(out.Hits))
|
|
return
|
|
}
|
|
mu.Lock()
|
|
durations = append(durations, d)
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
|
if len(durations) != workers*perWorker {
|
|
t.Fatalf("erfolgreiche anfragen = %d, want %d", len(durations), workers*perWorker)
|
|
}
|
|
p95 := durations[len(durations)*95/100]
|
|
t.Logf("anfragen=%d p50=%s p95=%s max=%s", len(durations), durations[len(durations)/2], p95, durations[len(durations)-1])
|
|
if p95 > responseTimeLimit {
|
|
t.Errorf("p95 = %s, grenzwert %s", p95, responseTimeLimit)
|
|
}
|
|
}
|