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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
205 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|