Files
patrickandClaude Sonnet 5 437b0e70dc
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
SRC-10: gespeicherte suchanfragen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 01:08:00 +02:00

205 lines
6.2 KiB
Go

package savedsearchapi
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/savedsearch"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testlock"
)
type denyList map[string]bool
func (d denyList) CanAccessDocument(_ context.Context, _, documentID string) (bool, error) {
return !d[documentID], nil
}
type fixture struct {
srv *httptest.Server
index *sql.DB
env *testenv.Env
}
func setup(t *testing.T, perms searchapi.PermissionChecker) *fixture {
t.Helper()
addr := os.Getenv("TEST_MANTICORE_ADDR")
if addr == "" {
t.Skip("TEST_MANTICORE_ADDR nicht gesetzt, Manticore-Integrationstest uebersprungen")
}
e := testenv.New(t)
testlock.Acquire(t, "manticore")
db, err := searchindex.Open(addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := searchindex.EnsureSchema(context.Background(), db); err != nil {
t.Fatal(err)
}
searchMux := http.NewServeMux()
searchapi.NewServer(db, perms).RegisterRoutes(searchMux)
mux := http.NewServeMux()
NewServer(e.Pool, searchMux).RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &fixture{srv: srv, index: db, env: e}
}
var idSeq = time.Now().UnixNano() & 0x3FFFFFFFFFFFFFFF
func (f *fixture) indexDoc(t *testing.T, title, tok, owner string) searchindex.Doc {
t.Helper()
idSeq++
d := searchindex.Doc{ID: idSeq, DocumentID: fmt.Sprintf("doc-%d", idSeq), Title: title, Body: tok, CreatedBy: owner,
CreatedAtSec: time.Now().Unix(), UpdatedAtSec: time.Now().Unix()}
if err := searchindex.Upsert(context.Background(), f.index, d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = searchindex.Delete(context.Background(), f.index, d.ID) })
return d
}
func (f *fixture) newUser(t *testing.T) string {
t.Helper()
var id string
if err := f.env.Pool.QueryRow(context.Background(), `INSERT INTO users (email, name) VALUES ($1, 'U') RETURNING id`,
fmt.Sprintf("ssapi-%d@example.test", time.Now().UnixNano())).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
func (f *fixture) do(t *testing.T, method, path string, body any) (int, []byte) {
t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, f.srv.URL+path, rd)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.Bytes()
}
type results struct {
Total int `json:"total"`
Hits []struct {
DocumentID string `json:"document_id"`
} `json:"hits"`
}
func (f *fixture) run(t *testing.T, caller, id string) results {
t.Helper()
status, body := f.do(t, "GET", "/saved-searches/"+id+"/results?caller="+caller, nil)
if status != 200 {
t.Fatalf("results status = %d: %s", status, body)
}
var out results
_ = json.Unmarshal(body, &out)
return out
}
func (f *fixture) save(t *testing.T, caller, name, query string, shared bool) savedsearch.Search {
t.Helper()
status, body := f.do(t, "POST", "/saved-searches?caller="+caller, map[string]any{"name": name, "query": query, "shared": shared})
if status != 201 {
t.Fatalf("create status = %d: %s", status, body)
}
var s savedsearch.Search
_ = json.Unmarshal(body, &s)
return s
}
func tokenFor() string { return fmt.Sprintf("zq%dx", time.Now().UnixNano()) }
func TestResultsAreDeterminedFreshOnEveryCall(t *testing.T) {
f := setup(t, denyList{})
tok := tokenFor()
owner := f.env.UserID
f.indexDoc(t, "Erstes", tok, owner)
s := f.save(t, owner, "Frisch", "q="+tok, false)
if got := f.run(t, owner, s.ID); got.Total != 1 {
t.Fatalf("vorher total = %d", got.Total)
}
f.indexDoc(t, "Zweites", tok, owner)
if got := f.run(t, owner, s.ID); got.Total != 2 {
t.Errorf("nach datenaenderung total = %d, want 2", got.Total)
}
}
func TestSharedSearchShowsEachCallerOnlyAllowedDocuments(t *testing.T) {
tok := tokenFor()
deny := denyList{}
f := setup(t, deny)
owner := f.env.UserID
other := f.newUser(t)
ownDoc := f.indexDoc(t, "Eigenes", tok, owner)
blocked := f.indexDoc(t, "Gesperrt", tok, owner)
foreign := f.indexDoc(t, "Fremd", tok, other)
deny[blocked.DocumentID] = true
s := f.save(t, owner, "Team", "q="+tok, true)
ownerRes := f.run(t, owner, s.ID)
if len(ownerRes.Hits) != 1 || ownerRes.Hits[0].DocumentID != ownDoc.DocumentID {
t.Errorf("besitzer sieht %+v", ownerRes.Hits)
}
otherRes := f.run(t, other, s.ID)
if len(otherRes.Hits) != 1 || otherRes.Hits[0].DocumentID != foreign.DocumentID {
t.Errorf("zweite person sieht %+v", otherRes.Hits)
}
private := f.save(t, owner, "Privat", "q="+tok, false)
if status, _ := f.do(t, "GET", "/saved-searches/"+private.ID+"/results?caller="+other, nil); status != 404 {
t.Errorf("private suche fuer fremde: status %d", status)
}
}
func TestHTTPLifecycleAndOwnerOnlyDelete(t *testing.T) {
f := setup(t, denyList{})
owner := f.env.UserID
other := f.newUser(t)
s := f.save(t, owner, "Suche", "q=abc", true)
status, body := f.do(t, "GET", "/saved-searches?caller="+other, nil)
var list []savedsearch.Search
_ = json.Unmarshal(body, &list)
if status != 200 || len(list) != 1 {
t.Fatalf("liste: %d %s", status, body)
}
if status, _ := f.do(t, "DELETE", "/saved-searches/"+s.ID+"?caller="+other, nil); status != 404 {
t.Errorf("fremdes loeschen: %d", status)
}
if status, _ := f.do(t, "PUT", "/saved-searches/"+s.ID+"?caller="+owner, map[string]any{"name": "Neu", "query": "q=abc", "shared": false}); status != 200 {
t.Errorf("umbenennen: %d", status)
}
if status, _ := f.do(t, "DELETE", "/saved-searches/"+s.ID+"?caller="+owner, nil); status != 204 {
t.Errorf("loeschen: %d", status)
}
if status, _ := f.do(t, "GET", "/saved-searches", nil); status != 400 {
t.Errorf("ohne caller: %d", status)
}
if status, _ := f.do(t, "POST", "/saved-searches?caller="+owner, map[string]any{"name": "", "query": "q=x"}); status != 400 {
t.Errorf("leerer name: %d", status)
}
}