SRC-10: gespeicherte suchanfragen
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) (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>
This commit is contained in:
1 parent
de717c7bba
commit
437b0e70dc
12 files changed
+914
No files matched your search
@@ -0,0 +1,157 @@
|
||||
package savedsearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("savedsearch: nicht gefunden")
|
||||
ErrInvalid = errors.New("savedsearch: ungueltige eingabe")
|
||||
ErrConflict = errors.New("savedsearch: name bereits vergeben")
|
||||
)
|
||||
|
||||
const (
|
||||
maxNameLength = 120
|
||||
maxQueryLength = 2000
|
||||
)
|
||||
|
||||
var transientParams = []string{"caller", "limit", "offset"}
|
||||
|
||||
type Search struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Shared bool `json:"shared"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const columns = `id, owner_id, name, query, shared, created_at, updated_at`
|
||||
|
||||
func scan(row pgx.Row) (Search, error) {
|
||||
var s Search
|
||||
err := row.Scan(&s.ID, &s.OwnerID, &s.Name, &s.Query, &s.Shared, &s.CreatedAt, &s.UpdatedAt)
|
||||
return s, err
|
||||
}
|
||||
|
||||
func normalize(name, query string) (string, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > maxNameLength {
|
||||
return "", "", fmt.Errorf("%w: name muss 1-%d zeichen lang sein", ErrInvalid, maxNameLength)
|
||||
}
|
||||
values, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%w: suchabfrage nicht lesbar", ErrInvalid)
|
||||
}
|
||||
for _, p := range transientParams {
|
||||
values.Del(p)
|
||||
}
|
||||
if strings.TrimSpace(values.Get("q")) == "" {
|
||||
return "", "", fmt.Errorf("%w: suchabfrage ohne suchbegriff", ErrInvalid)
|
||||
}
|
||||
encoded := values.Encode()
|
||||
if len(encoded) > maxQueryLength {
|
||||
return "", "", fmt.Errorf("%w: suchabfrage zu lang", ErrInvalid)
|
||||
}
|
||||
return name, encoded, nil
|
||||
}
|
||||
|
||||
func mapWriteError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "23505":
|
||||
return ErrConflict
|
||||
case "23503", "22P02":
|
||||
return fmt.Errorf("%w: unbekannter nutzer", ErrInvalid)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func Create(ctx context.Context, pool *pgxpool.Pool, ownerID, name, query string, shared bool) (Search, error) {
|
||||
name, query, err := normalize(name, query)
|
||||
if err != nil {
|
||||
return Search{}, err
|
||||
}
|
||||
out, err := scan(pool.QueryRow(ctx, `
|
||||
INSERT INTO saved_searches (owner_id, name, query, shared) VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+columns, ownerID, name, query, shared))
|
||||
if err != nil {
|
||||
return Search{}, mapWriteError(fmt.Errorf("savedsearch: anlegen: %w", err))
|
||||
}
|
||||
return out, audittrail.Record(ctx, pool, ownerID, "saved_search_created", "saved_search", out.ID,
|
||||
map[string]any{"name": out.Name, "shared": out.Shared})
|
||||
}
|
||||
|
||||
func Update(ctx context.Context, pool *pgxpool.Pool, ownerID, id, name, query string, shared bool) (Search, error) {
|
||||
name, query, err := normalize(name, query)
|
||||
if err != nil {
|
||||
return Search{}, err
|
||||
}
|
||||
out, err := scan(pool.QueryRow(ctx, `
|
||||
UPDATE saved_searches SET name = $3, query = $4, shared = $5, updated_at = now()
|
||||
WHERE id = $1 AND owner_id = $2 RETURNING `+columns, id, ownerID, name, query, shared))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Search{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Search{}, mapWriteError(fmt.Errorf("savedsearch: aendern: %w", err))
|
||||
}
|
||||
return out, audittrail.Record(ctx, pool, ownerID, "saved_search_updated", "saved_search", out.ID,
|
||||
map[string]any{"name": out.Name, "shared": out.Shared})
|
||||
}
|
||||
|
||||
func Delete(ctx context.Context, pool *pgxpool.Pool, ownerID, id string) error {
|
||||
tag, err := pool.Exec(ctx, `DELETE FROM saved_searches WHERE id = $1 AND owner_id = $2`, id, ownerID)
|
||||
if err != nil {
|
||||
return mapWriteError(fmt.Errorf("savedsearch: loeschen: %w", err))
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return audittrail.Record(ctx, pool, ownerID, "saved_search_deleted", "saved_search", id, nil)
|
||||
}
|
||||
|
||||
func Get(ctx context.Context, pool *pgxpool.Pool, callerID, id string) (Search, error) {
|
||||
out, err := scan(pool.QueryRow(ctx, `
|
||||
SELECT `+columns+` FROM saved_searches WHERE id = $1 AND (owner_id = $2 OR shared)`, id, callerID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Search{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Search{}, mapWriteError(fmt.Errorf("savedsearch: lesen: %w", err))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func List(ctx context.Context, pool *pgxpool.Pool, callerID string) ([]Search, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT `+columns+` FROM saved_searches WHERE owner_id = $1 OR shared
|
||||
ORDER BY (owner_id = $1) DESC, lower(name), id`, callerID)
|
||||
if err != nil {
|
||||
return nil, mapWriteError(fmt.Errorf("savedsearch: auflisten: %w", err))
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Search{}
|
||||
for rows.Next() {
|
||||
s, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("savedsearch: lesen: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package savedsearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
|
||||
)
|
||||
|
||||
func newUser(t *testing.T, e *testenv.Env) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := e.Pool.QueryRow(context.Background(), `INSERT INTO users (email, name) VALUES ($1, 'Zweit') RETURNING id`,
|
||||
fmt.Sprintf("savedsearch-%d@example.test", time.Now().UnixNano())).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestCreateNamesAndRestoresQuery(t *testing.T) {
|
||||
e := testenv.New(t)
|
||||
ctx := context.Background()
|
||||
s, err := Create(ctx, e.Pool, e.UserID, " Offene Rechnungen ", "q=rechnung&sort=updated&caller=x&offset=40&limit=5", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Name != "Offene Rechnungen" || s.Query != "q=rechnung&sort=updated" {
|
||||
t.Errorf("gespeichert: %+v", s)
|
||||
}
|
||||
got, err := Get(ctx, e.Pool, e.UserID, s.ID)
|
||||
if err != nil || got.Query != s.Query || got.Name != s.Name {
|
||||
t.Fatalf("wiederherstellen: %+v %v", got, err)
|
||||
}
|
||||
if _, err := Create(ctx, e.Pool, e.UserID, "offene rechnungen", "q=x", false); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("doppelter name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
e := testenv.New(t)
|
||||
ctx := context.Background()
|
||||
for name, c := range map[string][2]string{
|
||||
"kein name": {"", "q=x"},
|
||||
"nur blanks": {" ", "q=x"},
|
||||
"kein q": {"n", "sort=updated"},
|
||||
"leeres q": {"n", "q=%20"},
|
||||
"zu lang": {"n", "q=" + strings.Repeat("a", 2100)},
|
||||
"name lang": {strings.Repeat("n", 121), "q=x"},
|
||||
} {
|
||||
if _, err := Create(ctx, e.Pool, e.UserID, c[0], c[1], false); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := Create(ctx, e.Pool, "kein-uuid", "n", "q=x", false); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("unbekannter nutzer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedSearchVisibleToOthersPrivateNot(t *testing.T) {
|
||||
e := testenv.New(t)
|
||||
ctx := context.Background()
|
||||
other := newUser(t, e)
|
||||
shared, _ := Create(ctx, e.Pool, e.UserID, "Team", "q=vertrag", true)
|
||||
private, _ := Create(ctx, e.Pool, e.UserID, "Privat", "q=geheim", false)
|
||||
|
||||
list, err := List(ctx, e.Pool, other)
|
||||
if err != nil || len(list) != 1 || list[0].ID != shared.ID {
|
||||
t.Fatalf("fremde liste: %+v %v", list, err)
|
||||
}
|
||||
if _, err := Get(ctx, e.Pool, other, private.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("private suche fuer fremde lesbar: %v", err)
|
||||
}
|
||||
if _, err := Get(ctx, e.Pool, other, shared.ID); err != nil {
|
||||
t.Errorf("geteilte suche: %v", err)
|
||||
}
|
||||
if _, err := Update(ctx, e.Pool, other, shared.ID, "x", "q=x", false); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("fremde aenderung: %v", err)
|
||||
}
|
||||
mine, _ := List(ctx, e.Pool, e.UserID)
|
||||
if len(mine) != 2 {
|
||||
t.Errorf("eigene liste = %d", len(mine))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDoesNotAffectOthers(t *testing.T) {
|
||||
e := testenv.New(t)
|
||||
ctx := context.Background()
|
||||
other := newUser(t, e)
|
||||
shared, _ := Create(ctx, e.Pool, e.UserID, "Gemeinsam", "q=a", true)
|
||||
theirs, _ := Create(ctx, e.Pool, other, "Gemeinsam", "q=b", false)
|
||||
|
||||
if err := Delete(ctx, e.Pool, other, shared.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("fremdes loeschen: %v", err)
|
||||
}
|
||||
if err := Delete(ctx, e.Pool, e.UserID, shared.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := List(ctx, e.Pool, other)
|
||||
if len(list) != 1 || list[0].ID != theirs.ID {
|
||||
t.Errorf("andere nutzer beeintraechtigt: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRecorded(t *testing.T) {
|
||||
e := testenv.New(t)
|
||||
ctx := context.Background()
|
||||
s, _ := Create(ctx, e.Pool, e.UserID, "A", "q=a", false)
|
||||
_, _ = Update(ctx, e.Pool, e.UserID, s.ID, "B", "q=a", true)
|
||||
_ = Delete(ctx, e.Pool, e.UserID, s.ID)
|
||||
var n int
|
||||
if err := e.Pool.QueryRow(ctx, `SELECT count(*) FROM audit_log WHERE object_type = 'saved_search' AND object_reference = $1`, s.ID).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("audit-eintraege = %d, want 3", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package savedsearchapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/dms/internal/savedsearch"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
pool *pgxpool.Pool
|
||||
search http.Handler
|
||||
}
|
||||
|
||||
// search ist der Handler von GET /search; Aufrufe laufen mit der Identitaet
|
||||
// der aufrufenden Person, nie der des Besitzers.
|
||||
func NewServer(pool *pgxpool.Pool, search http.Handler) *Server {
|
||||
return &Server{pool: pool, search: search}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /saved-searches", s.handleList)
|
||||
mux.HandleFunc("POST /saved-searches", s.handleCreate)
|
||||
mux.HandleFunc("PUT /saved-searches/{id}", s.handleUpdate)
|
||||
mux.HandleFunc("DELETE /saved-searches/{id}", s.handleDelete)
|
||||
mux.HandleFunc("GET /saved-searches/{id}/results", s.handleResults)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, savedsearch.ErrInvalid):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, savedsearch.ErrNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, savedsearch.ErrConflict):
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func caller(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
c := strings.TrimSpace(r.URL.Query().Get("caller"))
|
||||
if c == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "fehlender pflicht-parameter \"caller\""})
|
||||
}
|
||||
return c, c != ""
|
||||
}
|
||||
|
||||
type input struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Shared bool `json:"shared"`
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request) (input, bool) {
|
||||
var in input
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungueltiger request-body"})
|
||||
return in, false
|
||||
}
|
||||
return in, true
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := savedsearch.List(r.Context(), s.pool, c)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := decode(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := savedsearch.Create(r.Context(), s.pool, c, in.Name, in.Query, in.Shared)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := decode(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := savedsearch.Update(r.Context(), s.pool, c, r.PathValue("id"), in.Name, in.Query, in.Shared)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := savedsearch.Delete(r.Context(), s.pool, c, r.PathValue("id")); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
saved, err := savedsearch.Get(r.Context(), s.pool, c, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
values, err := url.ParseQuery(saved.Query)
|
||||
if err != nil {
|
||||
writeError(w, savedsearch.ErrInvalid)
|
||||
return
|
||||
}
|
||||
values.Set("caller", c)
|
||||
for _, p := range []string{"limit", "offset"} {
|
||||
if v := r.URL.Query().Get(p); v != "" {
|
||||
values.Set(p, v)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/search?"+values.Encode(), nil).WithContext(r.Context())
|
||||
rec := httptest.NewRecorder()
|
||||
s.search.ServeHTTP(rec, req)
|
||||
for k, v := range rec.Header() {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.WriteHeader(rec.Code)
|
||||
_, _ = w.Write(rec.Body.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE saved_searches;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE saved_searches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 120),
|
||||
query TEXT NOT NULL CHECK (length(query) BETWEEN 1 AND 2000),
|
||||
shared BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_saved_searches_owner_name ON saved_searches (owner_id, lower(name));
|
||||
CREATE INDEX idx_saved_searches_shared ON saved_searches (shared) WHERE shared;
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useI18n } from "@nexarch/shl";
|
||||
import "../../lib/messages";
|
||||
import { SavedSearches } from "../../components/SavedSearches";
|
||||
import {
|
||||
MAX_TERM_LENGTH,
|
||||
PAGE_SIZE,
|
||||
@@ -94,6 +95,8 @@ function SearchView() {
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<SavedSearches currentQuery={key} />
|
||||
|
||||
{truncated && <p role="status">{t("dms.search.truncated").replace("{max}", String(MAX_TERM_LENGTH))}</p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{loading && <p role="status">{t("dms.search.loading")}</p>}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { axe } from "jest-axe";
|
||||
import { I18nProvider } from "@nexarch/shl";
|
||||
import { SavedSearches } from "./SavedSearches";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_SEARCH_API_URL: "http://backend.test", NEXT_PUBLIC_USER_ID: "me" };
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const list = [
|
||||
{ id: "s1", owner_id: "me", name: "Meine Rechnungen", query: "q=rechnung&sort=updated", shared: false },
|
||||
{ id: "s2", owner_id: "other", name: "Team Vertraege", query: "q=vertrag", shared: true },
|
||||
];
|
||||
const ui = (q = "q=neu") => render(<I18nProvider initialLocale="de"><SavedSearches currentQuery={q} /></I18nProvider>);
|
||||
|
||||
it("listet eigene und geteilte Suchen und stellt sie per Link wieder her (AK1, AK2)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
|
||||
ui();
|
||||
const own = await screen.findByRole("link", { name: "Meine Rechnungen" });
|
||||
expect(own.getAttribute("href")).toBe("/search?q=rechnung&sort=updated");
|
||||
expect(screen.getByRole("link", { name: "Team Vertraege" })).toBeTruthy();
|
||||
expect(screen.getByText("(geteilt)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("bietet Loeschen nur fuer eigene Suchen an", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
|
||||
ui();
|
||||
await screen.findByRole("link", { name: "Meine Rechnungen" });
|
||||
expect(screen.getAllByRole("button", { name: /Löschen:/ })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("speichert die aktuelle Suche mit Name und Teilen-Flag", async () => {
|
||||
const f = vi.fn().mockImplementation((_u: string, init?: RequestInit) =>
|
||||
Promise.resolve(new Response(init?.method === "POST" ? "{}" : "[]", { status: init?.method === "POST" ? 201 : 200 })));
|
||||
vi.stubGlobal("fetch", f);
|
||||
ui("q=neu&sort=updated");
|
||||
fireEvent.change(await screen.findByLabelText("Name der Suche"), { target: { value: "Neu" } });
|
||||
fireEvent.click(screen.getByLabelText("Mit dem Team teilen"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Aktuelle Suche speichern" }));
|
||||
await waitFor(() => expect(f.mock.calls.some((c) => c[1]?.method === "POST")).toBe(true));
|
||||
const post = f.mock.calls.find((c) => c[1]?.method === "POST")!;
|
||||
expect(JSON.parse(post[1].body)).toEqual({ name: "Neu", query: "q=neu&sort=updated", shared: true });
|
||||
});
|
||||
|
||||
it("hat keine axe-Verstoesse", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
|
||||
const { container } = ui();
|
||||
await screen.findByRole("link", { name: "Meine Rechnungen" });
|
||||
expect((await axe(container)).violations).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@nexarch/shl";
|
||||
import "../lib/messages";
|
||||
import { caller } from "../lib/caller";
|
||||
import { SavedSearch, createSavedSearch, deleteSavedSearch, fetchSavedSearches, savedSearchHref } from "../lib/savedSearchApi";
|
||||
|
||||
export function SavedSearches({ currentQuery }: { currentQuery: string }) {
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<SavedSearch[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [shared, setShared] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchSavedSearches()
|
||||
.then(setItems)
|
||||
.catch(() => setError(t("dms.saved.loadError")));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await createSavedSearch(name.trim(), currentQuery, shared);
|
||||
setName("");
|
||||
setShared(false);
|
||||
reload();
|
||||
} catch {
|
||||
setError(t("dms.saved.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await deleteSavedSearch(id);
|
||||
reload();
|
||||
} catch {
|
||||
setError(t("dms.saved.deleteError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="saved-searches-title" style={{ marginBottom: 16 }}>
|
||||
<h2 id="saved-searches-title">{t("dms.saved.title")}</h2>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{currentQuery && (
|
||||
<form onSubmit={save} style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 8 }}>
|
||||
<label htmlFor="saved-name" className="shl-visually-hidden">
|
||||
{t("dms.saved.name")}
|
||||
</label>
|
||||
<input
|
||||
id="saved-name"
|
||||
value={name}
|
||||
required
|
||||
maxLength={120}
|
||||
placeholder={t("dms.saved.name")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{ flex: "1 1 200px", minWidth: 0, padding: 8, minHeight: 44 }}
|
||||
/>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input type="checkbox" checked={shared} onChange={(e) => setShared(e.target.checked)} />
|
||||
{t("dms.saved.share")}
|
||||
</label>
|
||||
<button type="submit" style={{ minHeight: 44 }}>{t("dms.saved.save")}</button>
|
||||
</form>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<p>{t("dms.saved.empty")}</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{items.map((s) => (
|
||||
<li key={s.id} style={{ display: "flex", gap: 8, alignItems: "center", padding: "4px 0" }}>
|
||||
<a href={savedSearchHref(s)} style={{ overflowWrap: "anywhere" }}>
|
||||
{s.name}
|
||||
</a>
|
||||
{s.shared && <span>({t("dms.saved.shared")})</span>}
|
||||
{s.owner_id === caller() && (
|
||||
<button type="button" style={{ minHeight: 44 }} onClick={() => remove(s.id)} aria-label={`${t("dms.saved.delete")}: ${s.name}`}>
|
||||
{t("dms.saved.delete")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,17 @@ registerMessages("de", {
|
||||
"dms.search.paging": "Seitenwechsel der Trefferliste",
|
||||
"dms.search.prev": "Zurück",
|
||||
"dms.search.next": "Weiter",
|
||||
"dms.saved.title": "Gespeicherte Suchen",
|
||||
"dms.saved.empty": "Noch keine gespeicherten Suchen.",
|
||||
"dms.saved.name": "Name der Suche",
|
||||
"dms.saved.share": "Mit dem Team teilen",
|
||||
"dms.saved.save": "Aktuelle Suche speichern",
|
||||
"dms.saved.open": "Aufrufen",
|
||||
"dms.saved.delete": "Löschen",
|
||||
"dms.saved.shared": "geteilt",
|
||||
"dms.saved.loadError": "Gespeicherte Suchen konnten nicht geladen werden.",
|
||||
"dms.saved.saveError": "Die Suche konnte nicht gespeichert werden.",
|
||||
"dms.saved.deleteError": "Die Suche konnte nicht gelöscht werden.",
|
||||
});
|
||||
|
||||
registerMessages("en", {
|
||||
@@ -251,4 +262,15 @@ registerMessages("en", {
|
||||
"dms.search.paging": "Result list paging",
|
||||
"dms.search.prev": "Previous",
|
||||
"dms.search.next": "Next",
|
||||
"dms.saved.title": "Saved searches",
|
||||
"dms.saved.empty": "No saved searches yet.",
|
||||
"dms.saved.name": "Search name",
|
||||
"dms.saved.share": "Share with the team",
|
||||
"dms.saved.save": "Save current search",
|
||||
"dms.saved.open": "Run",
|
||||
"dms.saved.delete": "Delete",
|
||||
"dms.saved.shared": "shared",
|
||||
"dms.saved.loadError": "Saved searches could not be loaded.",
|
||||
"dms.saved.saveError": "The search could not be saved.",
|
||||
"dms.saved.deleteError": "The search could not be deleted.",
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { createSavedSearch, deleteSavedSearch, fetchSavedSearches, savedSearchHref } from "./savedSearchApi";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_SEARCH_API_URL: "http://backend.test", NEXT_PUBLIC_USER_ID: "user-1" };
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("liest die Liste mit caller", async () => {
|
||||
const f = vi.fn().mockResolvedValue(new Response("[]", { status: 200 }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
expect(await fetchSavedSearches()).toEqual([]);
|
||||
expect(f.mock.calls[0][0]).toBe("http://backend.test/saved-searches?caller=user-1");
|
||||
});
|
||||
|
||||
it("legt eine Suche mit Name, Abfrage und Teilen-Flag an", async () => {
|
||||
const f = vi.fn().mockResolvedValue(new Response("{}", { status: 201 }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
await createSavedSearch("Rechnungen", "q=rechnung", true);
|
||||
expect(f.mock.calls[0][1].method).toBe("POST");
|
||||
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ name: "Rechnungen", query: "q=rechnung", shared: true });
|
||||
});
|
||||
|
||||
it("loescht per DELETE und meldet Fehler", async () => {
|
||||
const f = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
|
||||
vi.stubGlobal("fetch", f);
|
||||
await expect(deleteSavedSearch("s1")).rejects.toThrow("404");
|
||||
expect(f.mock.calls[0][0]).toBe("http://backend.test/saved-searches/s1?caller=user-1");
|
||||
});
|
||||
|
||||
it("stellt die Suche ueber die Such-URL wieder her (AK1)", () => {
|
||||
expect(savedSearchHref({ query: "q=rechnung&sort=updated" })).toBe("/search?q=rechnung&sort=updated");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { withCaller } from "./caller";
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_SEARCH_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("NEXT_PUBLIC_SEARCH_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function fetchSavedSearches(): Promise<SavedSearch[]> {
|
||||
const res = await fetch(withCaller(`${apiBase()}/saved-searches`));
|
||||
if (!res.ok) throw new Error(`Gespeicherte Suchen konnten nicht geladen werden (${res.status})`);
|
||||
const body = await res.json();
|
||||
return Array.isArray(body) ? body : [];
|
||||
}
|
||||
|
||||
export async function createSavedSearch(name: string, query: string, shared: boolean): Promise<SavedSearch> {
|
||||
const res = await fetch(withCaller(`${apiBase()}/saved-searches`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, query, shared }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Suche konnte nicht gespeichert werden (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSavedSearch(id: string): Promise<void> {
|
||||
const res = await fetch(withCaller(`${apiBase()}/saved-searches/${encodeURIComponent(id)}`), { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`Suche konnte nicht gelöscht werden (${res.status})`);
|
||||
}
|
||||
|
||||
export function savedSearchHref(s: Pick<SavedSearch, "query">): string {
|
||||
return `/search?${s.query}`;
|
||||
}
|
||||
Reference in new issue
Block a user