package triggerapi import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "gitea.perlbach24.de/scripte/nexarch/dms/internal/tag" "gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv" "gitea.perlbach24.de/scripte/nexarch/dms/internal/trigger" ) // memVault verpackt reversibel (Praefix) statt echt zu verschluesseln, damit // der Test einen echten Seal/Open-Roundtrip pruefen kann, ohne einen // KEK-Provider hochzufahren. type memVault struct{} var _ trigger.Vault = memVault{} func (memVault) Seal(_ context.Context, _, plaintext string) ([]byte, []byte, error) { return []byte("dek"), []byte("enc:" + plaintext), nil } func (memVault) Open(_ context.Context, _ string, _, ciphertext []byte) (string, error) { return strings.TrimPrefix(string(ciphertext), "enc:"), nil } func TestAPI_ManageTriggersOverHTTP(t *testing.T) { e := testenv.New(t) tg, err := tag.Create(t.Context(), e.Pool, "rechnung", "", nil) if err != nil { t.Fatal(err) } mux := http.NewServeMux() NewServer(e.Pool, memVault{}, testenv.TenantSlug).RegisterRoutes(mux) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) do := func(method, path string, body any) (int, []byte) { var r *http.Request var err error if body != nil { b, _ := json.Marshal(body) r, err = http.NewRequest(method, srv.URL+path, bytes.NewReader(b)) } else { r, err = http.NewRequest(method, srv.URL+path, nil) } if err != nil { t.Fatal(err) } resp, err := http.DefaultClient.Do(r) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) return resp.StatusCode, buf.Bytes() } caller := "?caller=" + e.UserID trg := map[string]any{"name": "Rechnung", "event": "document.uploaded", "filename_pattern": "(?i)rechnung", "action_type": "set_tag", "action_tag_id": tg.ID} if status, _ := do(http.MethodPost, "/triggers", trg); status != http.StatusBadRequest { t.Errorf("ohne caller: %d, want 400", status) } status, body := do(http.MethodPost, "/triggers"+caller, trg) if status != http.StatusCreated { t.Fatalf("anlegen: %d %s", status, body) } var created map[string]any _ = json.Unmarshal(body, &created) id := created["id"].(string) if created["enabled"] != true { t.Errorf("neuer trigger sollte aktiviert sein: %+v", created) } bad := map[string]any{"name": "kaputt", "event": "document.uploaded", "filename_pattern": "([", "action_type": "set_tag", "action_tag_id": tg.ID} if status, _ := do(http.MethodPost, "/triggers"+caller, bad); status != http.StatusBadRequest { t.Errorf("ungueltiges muster: %d, want 400", status) } status, body = do(http.MethodGet, "/triggers", nil) var list []map[string]any _ = json.Unmarshal(body, &list) if status != http.StatusOK || len(list) != 1 { t.Errorf("liste: %d %s", status, body) } if _, hasSecret := list[0]["webhook_secret"]; hasSecret { t.Error("die liste darf kein geheimnis enthalten") } if status, _ := do(http.MethodGet, "/triggers/"+id, nil); status != http.StatusOK { t.Errorf("lesen: %d", status) } if status, _ := do(http.MethodGet, "/triggers/00000000-0000-0000-0000-000000000001", nil); status != http.StatusNotFound { t.Errorf("unbekannt: %d, want 404", status) } if status, _ := do(http.MethodPost, "/triggers/"+id+"/disable"+caller, nil); status != http.StatusNoContent { t.Errorf("deaktivieren: %d", status) } _, body = do(http.MethodGet, "/triggers/"+id, nil) var after map[string]any _ = json.Unmarshal(body, &after) if after["enabled"] != false { t.Errorf("trigger sollte deaktiviert sein: %+v", after) } if status, _ := do(http.MethodPost, "/triggers/"+id+"/enable"+caller, nil); status != http.StatusNoContent { t.Errorf("aktivieren: %d", status) } if status, _ := do(http.MethodDelete, "/triggers/"+id+caller, nil); status != http.StatusNoContent { t.Errorf("loeschen: %d", status) } if status, _ := do(http.MethodGet, "/triggers/"+id, nil); status != http.StatusNotFound { t.Errorf("nach loeschen: %d, want 404", status) } if status, _ := do(http.MethodDelete, "/triggers/"+id+caller, nil); status != http.StatusNotFound { t.Errorf("doppelt loeschen: %d, want 404", status) } } func TestAPI_WebhookSecretNeverReturned(t *testing.T) { e := testenv.New(t) mux := http.NewServeMux() NewServer(e.Pool, memVault{}, testenv.TenantSlug).RegisterRoutes(mux) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) url := "https://example.test/hook" trg := map[string]any{"name": "Hook", "event": "document.uploaded", "action_type": "webhook", "webhook_url": url, "webhook_secret": "top-secret"} b, _ := json.Marshal(trg) resp, err := http.Post(srv.URL+"/triggers?caller="+e.UserID, "application/json", bytes.NewReader(b)) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() var out bytes.Buffer _, _ = out.ReadFrom(resp.Body) if bytes.Contains(out.Bytes(), []byte("top-secret")) { t.Fatalf("antwort enthaelt das geheimnis im klartext: %s", out.String()) } var wrapped, enc []byte if err := e.Pool.QueryRow(t.Context(), `SELECT webhook_secret_wrapped_dek, webhook_secret_enc FROM triggers WHERE webhook_url = $1`, url).Scan(&wrapped, &enc); err != nil { t.Fatal(err) } if bytes.Equal(enc, []byte("top-secret")) { t.Fatal("geheimnis liegt unverschluesselt in der datenbank") } if len(wrapped) == 0 { t.Fatal("kein verpackter schluessel gespeichert") } }