Merge PR #318: API-01 (Connect): Connect-Konfigurationsoberflaeche (Connectoren einrichten)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s

This commit is contained in:
patrick committed 2026-10-19 02:19:47 +02:00
commit 7b9e6b94ce
23 files changed
+5126 -5

No files matched your search

+29 -5
View File
@@ -1,11 +1,11 @@
// Command connect-app is the first HTTP entrypoint for the NEXARCH
// Connect module. For now it only serves the connector status overview
// (API-02); further endpoints join this ServeMux as later tickets are
// implemented.
// Command connect-app is the HTTP entrypoint for the NEXARCH Connect
// module: connector status (API-02), DMS2 import (DMS2-01/02), and
// connector setup (API-01).
package main
import (
"context"
"encoding/base64"
"log"
"net/http"
"os"
@@ -15,6 +15,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorsetupapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2import"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2importapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
@@ -23,6 +26,15 @@ import (
"gitea.perlbach24.de/scripte/nexarch/connect/internal/syncjob"
)
// fixedKEK is a transitional KEKProvider: a single, operator-provisioned
// key for every tenant, used until API-10/API-12's real Core KMS client
// exists. The key never touches the code, only NEXARCH_CONNECT_APP_KEK.
type fixedKEK struct{ kek []byte }
func (f fixedKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
return f.kek, nil
}
func main() {
if !killswitch.Enabled() {
log.Printf("connect-app: deaktiviert (%s=false), kein Start", killswitch.EnvEnabled)
@@ -37,6 +49,14 @@ func main() {
if addr == "" {
addr = "127.0.0.1:9500"
}
kekB64 := os.Getenv("NEXARCH_CONNECT_APP_KEK")
if kekB64 == "" {
log.Fatal("connect-app: NEXARCH_CONNECT_APP_KEK ist erforderlich (base64, 32 Bytes)")
}
kek, err := base64.StdEncoding.DecodeString(kekB64)
if err != nil || len(kek) != crypto.KEKSize {
log.Fatalf("connect-app: NEXARCH_CONNECT_APP_KEK ungueltig (erwartet base64-kodierte %d Bytes)", crypto.KEKSize)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
@@ -45,10 +65,14 @@ func main() {
}
defer pool.Close()
mappings := fieldmapping.NewStore(pool)
credentials := credential.NewStore(pool, crypto.NewService(fixedKEK{kek: kek}))
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
statusapi.NewServer(syncjob.NewStore(pool)).RegisterRoutes(mux)
dms2importapi.NewServer(fieldmapping.NewStore(pool), dms2import.NewStore(pool)).RegisterRoutes(mux)
dms2importapi.NewServer(mappings, dms2import.NewStore(pool)).RegisterRoutes(mux)
connectorsetupapi.NewServer(credentials, mappings).RegisterRoutes(mux)
srv := &http.Server{
Addr: addr,
+83
View File
@@ -0,0 +1,83 @@
// Command setup-a11y-fixture wraps the real connect-app setup API
// (connectorsetupapi, API-01) with permissive CORS headers, exactly like
// status-a11y-fixture does for API-02: only this throwaway test binary
// allows cross-origin requests, so a browser-based run can serve the
// frontend and API on different localhost ports. The production
// connect-app binary is unchanged and relies on same-origin reverse-proxy
// routing, as every other module in this project does.
package main
import (
"context"
"encoding/base64"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorsetupapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
)
type fixedKEK struct{ kek []byte }
func (f fixedKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
return f.kek, nil
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-User-Id, X-Tenant-Slug, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
dsn := os.Getenv("NEXARCH_CONNECT_APP_TENANT_DSN")
if dsn == "" {
log.Fatal("setup-a11y-fixture: NEXARCH_CONNECT_APP_TENANT_DSN ist erforderlich")
}
addr := os.Getenv("NEXARCH_CONNECT_APP_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:9500"
}
kekB64 := os.Getenv("NEXARCH_CONNECT_APP_KEK")
if kekB64 == "" {
log.Fatal("setup-a11y-fixture: NEXARCH_CONNECT_APP_KEK ist erforderlich (base64, 32 Bytes)")
}
kek, err := base64.StdEncoding.DecodeString(kekB64)
if err != nil || len(kek) != crypto.KEKSize {
log.Fatalf("setup-a11y-fixture: NEXARCH_CONNECT_APP_KEK ungueltig (erwartet base64-kodierte %d Bytes)", crypto.KEKSize)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
log.Fatalf("setup-a11y-fixture: datenbankverbindung: %v", err)
}
defer pool.Close()
mux := http.NewServeMux()
credentials := credential.NewStore(pool, crypto.NewService(fixedKEK{kek: kek}))
connectorsetupapi.NewServer(credentials, fieldmapping.NewStore(pool)).RegisterRoutes(mux)
srv := &http.Server{
Addr: addr,
Handler: cors(mux),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("setup-a11y-fixture: listening on %s", addr)
log.Fatal(srv.ListenAndServe())
}
+59
View File
@@ -0,0 +1,59 @@
# API-01 – Connect-Konfigurationsoberfläche: Prüfprotokoll
Stand: 2026-10-16, Branch `feature/api-01-01-connect-konfigurationsoberflaeche-connectoren-einrichten`, geprüft auf Entwicklungshost 204 (eigene Wegwerf-DBs `api01_connect`/`api01_connect_a11y`, Rolle `api01_connect_test`; `archivdms` unberührt).
## Umsetzung
- **Backend:** `connect/internal/connectorcatalog` (neu) ist der statische Katalog der Connector-Typen (`erp`, `crm`, `m365`, `backup`, `datev`), je mit Zugangsdaten-Art (API-Key/OAuth2) und NEXARCH-Feld-Schema für `fieldmapping`. `connect/internal/credential` bekommt `ListForTenant` und die reine Funktion `Mask` (feste Maske `••••` + letzte 4 Zeichen, ab 8 Zeichen Länge vollständig verdeckt) — Erweiterung von ENG-02, kein Eingriff in dessen bestehende Methoden. `connect/internal/connectorsetupapi` (neu) bindet beides an HTTP: Katalog lesen, Verbindungen anlegen/maskiert auflisten (ENG-02), Feld-Mapping lesen/speichern (ENG-04). Identität übergangsweise über `X-User-Id`/`X-Tenant-Slug`, gleiches Muster wie `statusapi`/`dms2importapi`.
- **Schlüsselverwaltung:** `connect-app` braucht für `credential.Store` einen KEK-Provider. Da API-10/API-12 (Core-KMS-Client) noch nicht existieren, liest `connect-app` einen einzigen, operatorseitig vorgegebenen Schlüssel aus `NEXARCH_CONNECT_APP_KEK` (base64, 32 Byte) — übergangsweise, bis der echte KMS-Client existiert, nie im Code.
- **Oberfläche:** `web/connect-setup` (Next.js/React, `@nexarch/shl`-Design-System, gleiches Turbopack-Symlink→Kopie-Muster wie `web/connect-status`). Eine Seite `/setup`: Formular „Neue Verbindung" (Connector-Typ, Instanz-Kennung, Zugangsdaten je nach Art), Tabelle „Eingerichtete Verbindungen" mit maskierten Zugangsdaten, Feld-Mapping-Editor je Verbindung (ein Textfeld je Schema-Feld, Pflichtfelder markiert).
- **Test-Fixture:** `connect/cmd/setup-a11y-fixture` — CORS-Kopie von `connect-app`, analog `status-a11y-fixture`. Der Produktionsdienst bleibt ohne CORS.
## Akzeptanzkriterien
1. **Connector lässt sich vollständig über die Oberfläche einrichten, ohne Konfigurationsdatei** — erfüllt. Connector-Typ, Instanz-Kennung, Zugangsdaten und Feld-Mapping werden ausschließlich über Formulare erfasst und per HTTP an `connectorsetupapi` gesendet; keine YAML-/Env-Datei pro Connector-Instanz nötig.
2. **Zugangsdaten werden nur maskiert angezeigt, nie im Klartext** — erfüllt. `credential.Mask` liefert serverseitig nur `••••` + höchstens 4 Zeichen; die Oberfläche zeigt ausschließlich diesen Wert, Eingabefelder für neue Zugangsdaten werden nach dem Speichern geleert statt den Wert zurückzuspiegeln.
3. **Feld-Mapping ist über die Oberfläche bearbeitbar** — erfüllt. Mapping-Editor liest den aktiven Stand (`fieldmapping.Store.GetActive`) und speichert Änderungen als neue Version (`Save`), inklusive Ablehnung bei ungültigem Mapping (ENG-04 AC2).
## Prüfungen
Alle drei Prüfungen wurden sowohl als automatisierter Test (Go-Integrationstest gegen echte Postgres-Wegwerf-DB, Vitest mit echtem Komponenten-Rendering) als auch in einem echten Browserlauf durchgeführt.
1. **Vollständige Einrichtung eines Testconnectors über die Oberfläche erfolgreich** — bestanden.
- Go: `TestFullConnectorSetup_WithoutConfigFile` (`connect/internal/connectorsetupapi`) — Katalog abrufen, Verbindung `erp/acme-erp-1` per `PUT` anlegen, Mapping speichern, Verbindung in der Liste wiederfinden.
- Vitest: `richtet einen Testconnector vollstaendig ueber das Formular ein` — Formular ausgefüllt, abgesendet, neue Zeile in der Tabelle sichtbar.
- **Echter Browserlauf** (`web/a11y-audit/connect-setup-run.sh`, Chrome stable via puppeteer-core, echter `setup-a11y-fixture`-Dienst gegen echte Postgres-Daten): Connector-Typ „ERP-System" gewählt, Instanz-Kennung `browser-test-erp-1` und ein synthetischer API-Schlüssel eingegeben, Formular abgesendet — Ergebnis `OK: Pruefung 1`.
2. **Zugangsdaten in der Oberfläche im Test nachweislich maskiert** — bestanden.
- Go: `TestListConnections_CredentialsAreMasked` — Antwortkörper der Verbindungsliste enthält das eingegebene Geheimnis an keiner Stelle (`strings.Contains`-Prüfung), maskierter Wert beginnt mit `••••`.
- Vitest: `zeigt Zugangsdaten in der Verbindungsliste nur maskiert`.
- **Echter Browserlauf:** nach dem Anlegen der Verbindung enthält das gerenderte DOM (`page.content()`) das eingegebene Geheimnis `testconnector-geheimnis-7788991100` nachweislich nicht; die Tabelle zeigt `••••1100` — Ergebnis `OK: Pruefung 2`.
3. **Mapping-Änderung über die Oberfläche im Test korrekt übernommen** — bestanden.
- Go: `TestMapping_ChangeIsPickedUpOnNextRead` — Mapping speichern (Version 1), ändern (Version 2), erneut lesen liefert den geänderten Wert.
- Vitest: `uebernimmt eine Mapping-Aenderung korrekt` — Feld geändert, gespeichert, `saveMapping` mit den erwarteten Einträgen aufgerufen, neue Version angezeigt.
- **Echter Browserlauf:** Feld `kundennummer` auf `debitorennummer` gesetzt, gespeichert, Seite **neu geladen** (nicht nur lokaler React-State) und Mapping-Editor erneut geöffnet — der Wert `debitorennummer` ist weiterhin im Eingabefeld sichtbar — Ergebnis `OK: Pruefung 3`.
## Läufe auf 204
- `go build ./...`, `go vet ./...`, `gofmt -l .` (für die neu/ geänderten Dateien): sauber.
- `go test -count=1 -p 1 ./...` (echte Postgres-Wegwerf-DB `api01_connect`, Rolle `api01_connect_test`): alle 30 Pakete `ok`, inkl. `connectorcatalog`, `connectorsetupapi` (neu) und `credential` (erweitert).
- `web/connect-setup`: `tsc --noEmit` sauber, `vitest run` 4/4, `next build` erfolgreich (Routen `/`, `/setup`).
- **Echter Browserlauf** (`web/a11y-audit/connect-setup-run.sh`, DB `api01_connect_a11y`): alle drei Formular-Interaktionsprüfungen `OK`, anschließender axe-Audit **0 Verstöße, 0 offene Befunde** in hell und dunkel, kein horizontaler Überlauf, Tastaturerreichbarkeit 5/5 interaktive Elemente, sichtbarer Fokusring, `lang="de"`.
- Rückbau: Ports 9591/4191 (Testlauf) sind nach Skriptende frei (`ss -ltnp` leer), kein Prozess- oder Verzeichnisrest auf 204.
## QA und Entscheidungen
- **Connector-Katalog als statische Liste im neuen Backend-Paket statt Erweiterung von `registry.Connector`:** `ENG-01`s Registrierungsschnittstelle kennt pro Instanz nur `Metadata()` (Name/Version/Operationen), kein Feld-Schema. Eine Erweiterung dieser Schnittstelle oder der bestehenden Connector-Pakete (`erpconnector`, `crmconnector`, …) hätte deren Scope verlassen (kein Teil von API-01). Der Katalog ist reine Beschreibungsdaten für die Oberfläche, ohne Datenbanktabelle — kleinste Lösung.
- **Übergangsweiser env-basierter KEK statt echtem KMS-Client:** `credential.Store` war seit ENG-02 nur in Tests verdrahtet, nie in einem laufenden Dienst — ohne einen KEKProvider lässt sich die Oberfläche nicht real betreiben. API-10/API-12 (echter Core-KMS-Client) sind eigene, noch nicht gemergte Tickets; ein einzelner, aus `NEXARCH_CONNECT_APP_KEK` gelesener Schlüssel schließt genau diese Lücke, ohne deren Scope vorzugreifen.
- **Maskierung als feste Zeichenkette (`••••` + max. 4 Zeichen, ab 8 Zeichen Länge volle Verdeckung) statt reiner Längenangabe:** verhindert, dass die maskierte Anzeige selbst die Geheimnislänge verrät, und bleibt trotzdem erkennbar genug, um mehrere Verbindungen gleichen Typs zu unterscheiden.
- **Eigener Browser-Interaktions-Lauf (`connect-setup-interaction-check.js`) zusätzlich zum bestehenden `audit.js`:** `audit.js` lädt nur Seiten und prüft sie statisch; API-01 verlangt aber reale Dateneingabe (Formular ausfüllen, Connector anlegen, Mapping ändern). Das neue Skript nutzt dieselbe puppeteer-core-Infrastruktur, führt aber echte Klick-/Eingabeinteraktionen aus und prüft danach das gerenderte DOM — stärkerer Nachweis als ein reiner Vitest-Mock-Test.
- **2 Fix-Iterationen** (von bis zu 20 erlaubten): `aria-label` auf einem `<span>` ohne passende Rolle löste eine axe-`incomplete`-Meldung aus (behoben durch `data-masked="true"` statt `aria-label`); `A11Y_NODE_MODULES`-Pfad auf 204 liegt unter `/usr/lib/...`, nicht dem im bestehenden `audit.js`-Kommentar vermuteten `/usr/local/lib/...` (per Env-Variable korrigiert, kein Code-Eingriff in `audit.js` nötig).
## Fazit
Alle drei Akzeptanzkriterien und alle drei geforderten Prüfungen sind erfüllt und belegt (automatisierter Test plus echter Browserlauf je Kriterium). API-01 gilt als vollständig umgesetzt.
## Nicht Teil dieser Kachel
- `API-02` Verbindungsstatus-Übersicht & Diagnose (bereits fertig, nicht hier dupliziert).
- Verbindungs-Widerruf über die Oberfläche: `ENG-02`s `Store.Revoke` existiert bereits, ist aber kein Akzeptanzkriterium dieser Kachel und wurde daher nicht an die Oberfläche angebunden.
- Bildschirmleser-Stichprobe: nicht durchgeführt, gleiche organisatorische Lücke wie bei UX-06/UX-07/API-02 (kein NVDA/VoiceOver auf dem Testhost).
@@ -0,0 +1,92 @@
// Package connectorcatalog is the static list of connector types API-01's
// setup UI can offer: which credential kind each expects and which NEXARCH
// fields ENG-04's field mapping accepts for it. There is no per-tenant or
// per-instance configuration file behind this — every concrete connection
// (instance id, credentials, mapping) lives in the database via
// credential.Store/fieldmapping.Store, not here.
package connectorcatalog
import (
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
)
// ConnectorType describes one connector a tenant can set up.
type ConnectorType struct {
ID string
DisplayName string
CredentialKind credential.Kind
Schema []fieldmapping.FieldDef
}
var types = []ConnectorType{
{
ID: "erp",
DisplayName: "ERP-System",
CredentialKind: credential.KindAPIKey,
Schema: []fieldmapping.FieldDef{
{NexarchField: "kundennummer", Required: true},
{NexarchField: "name", Required: true},
{NexarchField: "ustid", Required: false},
{NexarchField: "email", Required: false},
},
},
{
ID: "crm",
DisplayName: "CRM-System",
CredentialKind: credential.KindOAuth2,
Schema: []fieldmapping.FieldDef{
{NexarchField: "kontakt_id", Required: true},
{NexarchField: "name", Required: true},
{NexarchField: "email", Required: false},
{NexarchField: "telefon", Required: false},
},
},
{
ID: "m365",
DisplayName: "Microsoft 365",
CredentialKind: credential.KindOAuth2,
Schema: []fieldmapping.FieldDef{
{NexarchField: "benutzer_id", Required: true},
{NexarchField: "anzeigename", Required: true},
{NexarchField: "email", Required: false},
},
},
{
ID: "backup",
DisplayName: "Backup-Ziel",
CredentialKind: credential.KindAPIKey,
Schema: []fieldmapping.FieldDef{
{NexarchField: "dokument_id", Required: true},
{NexarchField: "pfad", Required: true},
},
},
{
ID: "datev",
DisplayName: "DATEV",
CredentialKind: credential.KindAPIKey,
Schema: []fieldmapping.FieldDef{
{NexarchField: "belegnummer", Required: true},
{NexarchField: "betrag", Required: true},
{NexarchField: "buchungsdatum", Required: false},
},
},
}
// List returns every connector type, in a fixed, deterministic order.
func List() []ConnectorType {
out := make([]ConnectorType, len(types))
copy(out, types)
return out
}
// Get returns the connector type with the given id, or false if none
// matches.
func Get(id string) (ConnectorType, bool) {
for _, t := range types {
if t.ID == id {
return t, true
}
}
return ConnectorType{}, false
}
@@ -0,0 +1,31 @@
package connectorcatalog_test
import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorcatalog"
)
func TestList_ReturnsKnownTypesWithSchema(t *testing.T) {
types := connectorcatalog.List()
if len(types) == 0 {
t.Fatal("erwartet mindestens einen connector-typ")
}
for _, ct := range types {
if ct.ID == "" || ct.DisplayName == "" {
t.Fatalf("connector-typ ohne id/anzeigename: %+v", ct)
}
if len(ct.Schema) == 0 {
t.Fatalf("connector-typ %q ohne feld-schema", ct.ID)
}
}
}
func TestGet_KnownAndUnknownID(t *testing.T) {
if _, ok := connectorcatalog.Get("erp"); !ok {
t.Fatal("erwartet connector-typ 'erp'")
}
if _, ok := connectorcatalog.Get("does-not-exist"); ok {
t.Fatal("unbekannter connector-typ haette nicht gefunden werden duerfen")
}
}
@@ -0,0 +1,220 @@
// Package connectorsetupapi is the HTTP binding for API-01's connector
// setup UI: pick a connector type, store its credentials, edit its field
// mapping — entirely through this API, never a config file. Identity comes
// from X-User-Id/X-Tenant-Slug, the same transitional pattern
// connect/internal/statusapi already uses.
package connectorsetupapi
import (
"encoding/json"
"errors"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorcatalog"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
)
type Server struct {
credentials *credential.Store
mappings *fieldmapping.Store
}
func NewServer(credentials *credential.Store, mappings *fieldmapping.Store) *Server {
return &Server{credentials: credentials, mappings: mappings}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /connectors/catalog", s.requireIdentity(s.handleCatalog))
mux.HandleFunc("GET /connectors/connections", s.requireIdentity(s.handleListConnections))
mux.HandleFunc("PUT /connectors/connections/{type}/{instance}", s.requireIdentity(s.handlePutConnection))
mux.HandleFunc("GET /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.handleGetMapping))
mux.HandleFunc("PUT /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.handlePutMapping))
}
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
if userID == "" {
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
return
}
tenant := r.Header.Get("X-Tenant-Slug")
if tenant == "" {
writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt")
return
}
next(w, r, tenant, userID)
}
}
type connectorTypeView struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
CredentialKind string `json:"credential_kind"`
Schema []fieldmapping.FieldDef `json:"schema"`
}
func (s *Server) handleCatalog(w http.ResponseWriter, r *http.Request, _, _ string) {
types := connectorcatalog.List()
out := make([]connectorTypeView, 0, len(types))
for _, t := range types {
out = append(out, connectorTypeView{
ID: t.ID,
DisplayName: t.DisplayName,
CredentialKind: string(t.CredentialKind),
Schema: t.Schema,
})
}
writeJSON(w, http.StatusOK, out)
}
type connectionView struct {
ConnectorType string `json:"connector_type"`
ConnectorTypeLabel string `json:"connector_type_label"`
ConnectorInstance string `json:"connector_instance_id"`
CredentialKind string `json:"credential_kind"`
MaskedCredential string `json:"masked_credential"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request, tenantSlug, _ string) {
summaries, err := s.credentials.ListForTenant(r.Context(), tenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "verbindungen konnten nicht gelesen werden")
return
}
out := make([]connectionView, 0, len(summaries))
for _, cs := range summaries {
label := cs.Ref.ConnectorName
if ct, ok := connectorcatalog.Get(cs.Ref.ConnectorName); ok {
label = ct.DisplayName
}
out = append(out, connectionView{
ConnectorType: cs.Ref.ConnectorName,
ConnectorTypeLabel: label,
ConnectorInstance: cs.Ref.ConnectorInstanceID,
CredentialKind: string(cs.Kind),
MaskedCredential: cs.Masked,
ExpiresAt: cs.ExpiresAt,
UpdatedAt: cs.UpdatedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
type putConnectionRequest struct {
Kind string `json:"kind"`
APIKey string `json:"api_key"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt *time.Time `json:"expires_at"`
}
func (s *Server) handlePutConnection(w http.ResponseWriter, r *http.Request, tenantSlug, userID string) {
connectorType := r.PathValue("type")
instance := r.PathValue("instance")
ct, ok := connectorcatalog.Get(connectorType)
if !ok {
writeError(w, http.StatusNotFound, "unbekannter connector-typ")
return
}
var req putConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfragekoerper")
return
}
kind := credential.Kind(req.Kind)
if kind != ct.CredentialKind {
writeError(w, http.StatusBadRequest, "zugangsdaten-art passt nicht zum connector-typ")
return
}
secret := credential.Secret{APIKey: req.APIKey, AccessToken: req.AccessToken, RefreshToken: req.RefreshToken}
if (kind == credential.KindAPIKey && secret.APIKey == "") || (kind == credential.KindOAuth2 && secret.AccessToken == "") {
writeError(w, http.StatusBadRequest, "zugangsdaten fehlen")
return
}
ref := credential.Ref{TenantSlug: tenantSlug, ConnectorName: connectorType, ConnectorInstanceID: instance}
if err := s.credentials.PutWithActor(r.Context(), ref, kind, secret, req.ExpiresAt, userID); err != nil {
writeError(w, http.StatusInternalServerError, "verbindung konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, connectionView{
ConnectorType: connectorType,
ConnectorTypeLabel: ct.DisplayName,
ConnectorInstance: instance,
CredentialKind: string(kind),
MaskedCredential: credential.Mask(secret, kind),
ExpiresAt: req.ExpiresAt,
UpdatedAt: time.Now().UTC(),
})
}
type mappingView struct {
Schema []fieldmapping.FieldDef `json:"schema"`
Fields []fieldmapping.Entry `json:"fields"`
Version int `json:"version"`
}
func (s *Server) handleGetMapping(w http.ResponseWriter, r *http.Request, tenantSlug, _ string) {
connectorType := r.PathValue("type")
instance := r.PathValue("instance")
ct, ok := connectorcatalog.Get(connectorType)
if !ok {
writeError(w, http.StatusNotFound, "unbekannter connector-typ")
return
}
ref := fieldmapping.Ref{TenantSlug: tenantSlug, ConnectorName: connectorType, ConnectorInstanceID: instance}
m, err := s.mappings.GetActive(r.Context(), ref)
if errors.Is(err, fieldmapping.ErrNotFound) {
writeJSON(w, http.StatusOK, mappingView{Schema: ct.Schema, Fields: []fieldmapping.Entry{}})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "mapping konnte nicht gelesen werden")
return
}
writeJSON(w, http.StatusOK, mappingView{Schema: ct.Schema, Fields: m.Fields, Version: m.Version})
}
type putMappingRequest struct {
Fields []fieldmapping.Entry `json:"fields"`
}
func (s *Server) handlePutMapping(w http.ResponseWriter, r *http.Request, tenantSlug, _ string) {
connectorType := r.PathValue("type")
instance := r.PathValue("instance")
ct, ok := connectorcatalog.Get(connectorType)
if !ok {
writeError(w, http.StatusNotFound, "unbekannter connector-typ")
return
}
var req putMappingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfragekoerper")
return
}
ref := fieldmapping.Ref{TenantSlug: tenantSlug, ConnectorName: connectorType, ConnectorInstanceID: instance}
m, err := s.mappings.Save(r.Context(), ref, req.Fields, ct.Schema)
if errors.Is(err, fieldmapping.ErrInvalidConfig) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "mapping konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, mappingView{Schema: ct.Schema, Fields: m.Fields, Version: m.Version})
}
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, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
@@ -0,0 +1,275 @@
package connectorsetupapi
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
type fakeKEK struct{ kek []byte }
func (f *fakeKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
return f.kek, nil
}
func newTestServer(t *testing.T) *http.ServeMux {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool := testdb.Pool(t, dsn)
root, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
migrations, err := migrate.Load(root)
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
cryptoSvc := connectcrypto.NewService(&fakeKEK{kek: bytes.Repeat([]byte{0x11}, connectcrypto.KEKSize)})
s := NewServer(credential.NewStore(pool, cryptoSvc), fieldmapping.NewStore(pool))
mux := http.NewServeMux()
s.RegisterRoutes(mux)
return mux
}
func authedRequest(method, url string, body any) *http.Request {
var reader io.Reader
if body != nil {
payload, _ := json.Marshal(body)
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, url, reader)
req.Header.Set("X-User-Id", "u1")
req.Header.Set("X-Tenant-Slug", "acme")
req.Header.Set("Content-Type", "application/json")
return req
}
func doJSON(t *testing.T, mux *http.ServeMux, method, url string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
req := authedRequest(method, url, body)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
var decoded map[string]any
if w.Body.Len() > 0 {
if err := json.Unmarshal(w.Body.Bytes(), &decoded); err != nil {
t.Fatalf("antwort kein json-objekt: %v, body=%s", err, w.Body.String())
}
}
return w, decoded
}
// AC1/Pruefung 1: ein Testconnector laesst sich vollstaendig ueber die
// Oberflaeche (diese API, die die Oberflaeche aufruft) einrichten — ohne
// Konfigurationsdatei, nur ueber HTTP-Aufrufe mit Daten aus der Oberflaeche.
func TestFullConnectorSetup_WithoutConfigFile(t *testing.T) {
mux := newTestServer(t)
wCatalog := httptest.NewRecorder()
mux.ServeHTTP(wCatalog, authedRequest("GET", "/connectors/catalog", nil))
if wCatalog.Code != http.StatusOK {
t.Fatalf("katalog status = %d", wCatalog.Code)
}
var catalog []connectorTypeView
if err := json.Unmarshal(wCatalog.Body.Bytes(), &catalog); err != nil {
t.Fatalf("katalog decode: %v", err)
}
found := false
for _, ct := range catalog {
if ct.ID == "erp" {
found = true
}
}
if !found {
t.Fatalf("erwartet connector-typ 'erp' im katalog: %+v", catalog)
}
wConn, _ := doJSON(t, mux, "PUT", "/connectors/connections/erp/acme-erp-1", putConnectionRequest{
Kind: "api_key",
APIKey: "testconnector-geheimnis-1234567890",
})
if wConn.Code != http.StatusOK {
t.Fatalf("verbindung anlegen status = %d, body=%s", wConn.Code, wConn.Body.String())
}
wMapping, _ := doJSON(t, mux, "PUT", "/connectors/connections/erp/acme-erp-1/mapping", putMappingRequest{
Fields: []fieldmapping.Entry{
{NexarchField: "kundennummer", ExternalField: "debitorennummer"},
{NexarchField: "name", ExternalField: "name"},
},
})
if wMapping.Code != http.StatusOK {
t.Fatalf("mapping speichern status = %d, body=%s", wMapping.Code, wMapping.Body.String())
}
wList := httptest.NewRecorder()
mux.ServeHTTP(wList, authedRequest("GET", "/connectors/connections", nil))
var connections []connectionView
if err := json.Unmarshal(wList.Body.Bytes(), &connections); err != nil {
t.Fatalf("verbindungsliste decode: %v", err)
}
var seen bool
for _, c := range connections {
if c.ConnectorType == "erp" && c.ConnectorInstance == "acme-erp-1" {
seen = true
}
}
if !seen {
t.Fatalf("eingerichtete verbindung nicht in der liste: %+v", connections)
}
}
// AC2/Pruefung 2: Zugangsdaten werden in der Verbindungsliste nachweislich
// nur maskiert zurueckgegeben, nie im Klartext.
func TestListConnections_CredentialsAreMasked(t *testing.T) {
mux := newTestServer(t)
secret := "klartext-der-niemals-rausgehen-darf-55443322"
doJSON(t, mux, "PUT", "/connectors/connections/backup/acme-backup-1", putConnectionRequest{
Kind: "api_key",
APIKey: secret,
})
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("GET", "/connectors/connections", nil))
if strings.Contains(w.Body.String(), secret) {
t.Fatalf("antwort enthaelt das geheimnis im klartext: %s", w.Body.String())
}
var connections []connectionView
if err := json.Unmarshal(w.Body.Bytes(), &connections); err != nil {
t.Fatalf("decode: %v", err)
}
var masked string
for _, c := range connections {
if c.ConnectorInstance == "acme-backup-1" {
masked = c.MaskedCredential
}
}
if masked == "" || !strings.HasPrefix(masked, "••••") || masked == secret {
t.Fatalf("zugangsdaten nicht nachweislich maskiert: %q", masked)
}
}
// AC3/Pruefung 3: eine Mapping-Aenderung ueber die Oberflaeche wird korrekt
// uebernommen (gelesen, geaendert, erneut gelesen).
func TestMapping_ChangeIsPickedUpOnNextRead(t *testing.T) {
mux := newTestServer(t)
doJSON(t, mux, "PUT", "/connectors/connections/crm/acme-crm-1", putConnectionRequest{
Kind: "oauth2",
AccessToken: "access-123456789",
})
doJSON(t, mux, "PUT", "/connectors/connections/crm/acme-crm-1/mapping", putMappingRequest{
Fields: []fieldmapping.Entry{
{NexarchField: "kontakt_id", ExternalField: "id"},
{NexarchField: "name", ExternalField: "company"},
},
})
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("GET", "/connectors/connections/crm/acme-crm-1/mapping", nil))
var first mappingView
if err := json.Unmarshal(w.Body.Bytes(), &first); err != nil {
t.Fatalf("decode: %v", err)
}
if len(first.Fields) != 2 || first.Version != 1 {
t.Fatalf("erste version nicht wie erwartet: %+v", first)
}
wUpdate, _ := doJSON(t, mux, "PUT", "/connectors/connections/crm/acme-crm-1/mapping", putMappingRequest{
Fields: []fieldmapping.Entry{
{NexarchField: "kontakt_id", ExternalField: "id"},
{NexarchField: "name", ExternalField: "contact_name"},
{NexarchField: "email", ExternalField: "email_address"},
},
})
if wUpdate.Code != http.StatusOK {
t.Fatalf("mapping aktualisieren status = %d", wUpdate.Code)
}
w2 := httptest.NewRecorder()
mux.ServeHTTP(w2, authedRequest("GET", "/connectors/connections/crm/acme-crm-1/mapping", nil))
var second mappingView
if err := json.Unmarshal(w2.Body.Bytes(), &second); err != nil {
t.Fatalf("decode: %v", err)
}
if second.Version != 2 || len(second.Fields) != 3 {
t.Fatalf("geaendertes mapping nicht uebernommen: %+v", second)
}
var contactName string
for _, e := range second.Fields {
if e.NexarchField == "name" {
contactName = e.ExternalField
}
}
if contactName != "contact_name" {
t.Fatalf("geaendertes feld nicht uebernommen: %+v", second.Fields)
}
}
// AC2 (ENG-04): ein strukturell ungueltiges Mapping wird beim Speichern
// abgewiesen statt stillschweigend uebernommen zu werden.
func TestMapping_RejectsUnknownField(t *testing.T) {
mux := newTestServer(t)
doJSON(t, mux, "PUT", "/connectors/connections/datev/acme-datev-1", putConnectionRequest{
Kind: "api_key",
APIKey: "datev-schluessel-abcdefgh",
})
w, body := doJSON(t, mux, "PUT", "/connectors/connections/datev/acme-datev-1/mapping", putMappingRequest{
Fields: []fieldmapping.Entry{
{NexarchField: "unbekanntes_feld", ExternalField: "x"},
},
})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body=%v", w.Code, body)
}
}
func TestPutConnection_RejectsMismatchedCredentialKind(t *testing.T) {
mux := newTestServer(t)
w, _ := doJSON(t, mux, "PUT", "/connectors/connections/erp/acme-erp-mismatch", putConnectionRequest{
Kind: "oauth2",
AccessToken: "irrelevant",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (erp erwartet api_key)", w.Code)
}
}
func TestPutConnection_UnknownConnectorType(t *testing.T) {
mux := newTestServer(t)
w, _ := doJSON(t, mux, "PUT", "/connectors/connections/does-not-exist/inst", putConnectionRequest{
Kind: "api_key",
APIKey: "x",
})
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestRequireIdentity_RejectsMissingHeaders(t *testing.T) {
mux := newTestServer(t)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/connectors/connections", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
}
@@ -0,0 +1,83 @@
// connections.go adds the read side API-01's setup UI needs: a masked
// listing of every connection a tenant already has (ENG-02 itself only
// exposed Get/Put/Revoke keyed by a single known Ref).
package credential
import (
"context"
"encoding/json"
"fmt"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
)
// ConnectionSummary is one row of connector_credentials, decrypted only far
// enough to produce Masked — the caller never sees the real secret.
type ConnectionSummary struct {
Ref Ref
Kind Kind
Masked string
ExpiresAt *time.Time
UpdatedAt time.Time
}
// ListForTenant returns a masked summary of every connection stored for
// tenantSlug, ordered by connector name and instance id.
func (s *Store) ListForTenant(ctx context.Context, tenantSlug string) ([]ConnectionSummary, error) {
rows, err := s.pool.Query(ctx, `
SELECT connector_name, connector_instance_id, kind, ciphertext, wrapped_dek, expires_at, updated_at
FROM connector_credentials
WHERE tenant_slug = $1
ORDER BY connector_name, connector_instance_id
`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("credential: list for tenant: %w", err)
}
defer rows.Close()
var out []ConnectionSummary
for rows.Next() {
var ref Ref
ref.TenantSlug = tenantSlug
var kind Kind
var ciphertext, wrappedDEK []byte
var expiresAt *time.Time
var updatedAt time.Time
if err := rows.Scan(&ref.ConnectorName, &ref.ConnectorInstanceID, &kind, &ciphertext, &wrappedDEK, &expiresAt, &updatedAt); err != nil {
return nil, fmt.Errorf("credential: scan connection: %w", err)
}
plaintext, err := s.crypto.Open(ctx, ref.TenantSlug, &crypto.Sealed{Ciphertext: ciphertext, WrappedDEK: wrappedDEK})
if err != nil {
return nil, fmt.Errorf("credential: decrypt for mask: %w", err)
}
var secret Secret
if err := json.Unmarshal(plaintext, &secret); err != nil {
return nil, fmt.Errorf("credential: unmarshal for mask: %w", err)
}
out = append(out, ConnectionSummary{
Ref: ref,
Kind: kind,
Masked: Mask(secret, kind),
ExpiresAt: expiresAt,
UpdatedAt: updatedAt,
})
}
return out, rows.Err()
}
// Mask returns a fixed-length, non-reversible preview of secret's primary
// value for kind: four bullet characters plus its last 4 characters, never
// more than half the secret. A secret of 8 characters or less is hidden
// completely instead, so a short value never has most of itself revealed.
func Mask(secret Secret, kind Kind) string {
value := secret.AccessToken
if kind == KindAPIKey {
value = secret.APIKey
}
const bullets = "••••"
if len(value) <= 8 {
return bullets
}
return bullets + value[len(value)-4:]
}
@@ -0,0 +1,82 @@
package credential_test
import (
"context"
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
)
// API-01 Pruefung 2: Zugangsdaten in der Oberflaeche nachweislich maskiert
// — hier auf Ebene des Datenzugriffs, den die Oberflaeche aufruft.
func TestStore_ListForTenant_MasksSecrets(t *testing.T) {
store, _ := setup(t)
ctx := context.Background()
apiKeyRef := credential.Ref{TenantSlug: "acme", ConnectorName: "erp", ConnectorInstanceID: "inst-list-1"}
apiKeySecret := credential.Secret{APIKey: "super-secret-plaintext-9999"}
if err := store.Put(ctx, apiKeyRef, credential.KindAPIKey, apiKeySecret, nil); err != nil {
t.Fatalf("put api key: %v", err)
}
oauthRef := credential.Ref{TenantSlug: "acme", ConnectorName: "crm", ConnectorInstanceID: "inst-list-2"}
oauthSecret := credential.Secret{AccessToken: "oauth-access-token-abcd1234", RefreshToken: "refresh-xyz"}
if err := store.Put(ctx, oauthRef, credential.KindOAuth2, oauthSecret, nil); err != nil {
t.Fatalf("put oauth: %v", err)
}
summaries, err := store.ListForTenant(ctx, "acme")
if err != nil {
t.Fatalf("list for tenant: %v", err)
}
if len(summaries) != 2 {
t.Fatalf("erwartet 2 verbindungen, habe %d", len(summaries))
}
for _, s := range summaries {
if strings.Contains(s.Masked, apiKeySecret.APIKey) || strings.Contains(s.Masked, oauthSecret.AccessToken) || strings.Contains(s.Masked, oauthSecret.RefreshToken) {
t.Fatalf("maskierte ansicht enthaelt klartext-geheimnis: %q", s.Masked)
}
if !strings.HasPrefix(s.Masked, "••••") {
t.Fatalf("maskierte ansicht beginnt nicht mit bullets: %q", s.Masked)
}
}
var sawAPIKey, sawOAuth bool
for _, s := range summaries {
switch s.Ref.ConnectorInstanceID {
case "inst-list-1":
sawAPIKey = true
if s.Masked != "••••9999" {
t.Fatalf("falsche maskierung fuer api key: %q", s.Masked)
}
case "inst-list-2":
sawOAuth = true
if s.Masked != "••••1234" {
t.Fatalf("falsche maskierung fuer oauth token: %q", s.Masked)
}
}
}
if !sawAPIKey || !sawOAuth {
t.Fatalf("nicht beide verbindungen gefunden: %+v", summaries)
}
}
func TestStore_ListForTenant_EmptyForUnknownTenant(t *testing.T) {
store, _ := setup(t)
summaries, err := store.ListForTenant(context.Background(), "nobody-here")
if err != nil {
t.Fatalf("list for tenant: %v", err)
}
if len(summaries) != 0 {
t.Fatalf("erwartet keine verbindungen, habe %d", len(summaries))
}
}
func TestMask_ShortSecretStaysFullyHidden(t *testing.T) {
m := credential.Mask(credential.Secret{APIKey: "ab"}, credential.KindAPIKey)
if m != "••••" {
t.Fatalf("kurzes geheimnis sollte vollstaendig verdeckt sein, habe %q", m)
}
}
@@ -0,0 +1,131 @@
// API-01: echter Browserlauf (Chrome ueber puppeteer-core, wie
// web/a11y-audit/audit.js) gegen den echten setup-a11y-fixture-Dienst und
// eine echte Postgres-Wegwerf-DB. Statt nur eine Seite zu laden, fuellt
// und sendet dieses Skript das echte Formular, genau wie eine Nutzerin es
// taete, und prueft das gerenderte DOM danach.
//
// A11Y_CHROME=/pfad/chrome A11Y_WEB_URL=http://127.0.0.1:4191 node connect-setup-interaction-check.js
const path = require("path");
const nm = process.env.A11Y_NODE_MODULES || "/usr/lib/node_modules/pa11y/node_modules";
const puppeteer = require(path.join(nm, "puppeteer-core"));
const SECRET = "testconnector-geheimnis-7788991100";
function fail(msg) {
console.error("FEHLGESCHLAGEN: " + msg);
process.exitCode = 1;
}
(async () => {
const browser = await puppeteer.launch({
executablePath: process.env.A11Y_CHROME,
headless: "new",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
const base = process.env.A11Y_WEB_URL;
if (!base) throw new Error("A11Y_WEB_URL fehlt");
await page.goto(base + "/setup", { waitUntil: "networkidle0" });
// --- Pruefung 1: vollstaendige Einrichtung eines Testconnectors ---
await page.waitForSelector("form");
// Connector-Typ-Select: Wert "erp" ist per Katalog vorhanden.
const typeSelectId = await page.evaluate(() => {
const label = Array.from(document.querySelectorAll("label")).find((l) => l.textContent.includes("Connector-Typ"));
return label ? label.getAttribute("for") : null;
});
if (!typeSelectId) throw new Error("Connector-Typ-Feld nicht gefunden");
await page.select("#" + typeSelectId, "erp");
const instanceId = await page.evaluate(() => {
const label = Array.from(document.querySelectorAll("label")).find((l) => l.textContent.includes("Instanz-Kennung"));
return label ? label.getAttribute("for") : null;
});
await page.type("#" + instanceId, "browser-test-erp-1");
const apiKeyId = await page.evaluate(() => {
const label = Array.from(document.querySelectorAll("label")).find((l) => l.textContent.includes("API-Schlüssel"));
return label ? label.getAttribute("for") : null;
});
if (!apiKeyId) throw new Error("API-Schluessel-Feld nicht gefunden (Connector-Typ falsch gewaehlt?)");
await page.type("#" + apiKeyId, SECRET);
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll("button")).find((b) => b.textContent.includes("Verbindung speichern"));
btn.click();
});
await page.waitForFunction(
(id) => document.body.innerText.includes(id),
{ timeout: 10000 },
"browser-test-erp-1"
);
const bodyHTML = await page.content();
if (bodyHTML.includes(SECRET)) {
fail("das im Formular eingegebene Geheimnis erscheint im Klartext im gerenderten DOM");
} else {
console.log("OK: Pruefung 1 (vollstaendige Einrichtung) + kein Klartext-Geheimnis im DOM nach dem Anlegen");
}
// --- Pruefung 2: Zugangsdaten nachweislich maskiert ---
const maskedText = await page.evaluate(() => {
const cell = document.querySelector('[data-masked="true"]');
return cell ? cell.textContent : null;
});
if (!maskedText || !maskedText.startsWith("••••") || maskedText === SECRET) {
fail("Zugangsdaten in der Verbindungsliste sind nicht nachweislich maskiert: " + maskedText);
} else {
console.log("OK: Pruefung 2 (maskierte Anzeige) — gerenderter Wert: " + maskedText);
}
// --- Pruefung 3: Feld-Mapping-Aenderung wird korrekt uebernommen ---
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll("button")).find((b) => b.textContent.trim() === "Bearbeiten");
btn.click();
});
await page.waitForFunction(() => document.body.innerText.includes("Version"), { timeout: 10000 });
const firstFieldId = await page.evaluate(() => {
const label = Array.from(document.querySelectorAll("label")).find((l) => l.textContent.startsWith("kundennummer"));
return label ? label.getAttribute("for") : null;
});
if (!firstFieldId) throw new Error("Mapping-Feld 'kundennummer' nicht gefunden");
await page.type("#" + firstFieldId, "debitorennummer");
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll("button")).find((b) => b.textContent.includes("Mapping speichern"));
btn.click();
});
await page.waitForFunction(() => document.body.innerText.includes("Version 1"), { timeout: 10000 });
// Seite neu laden und die Mapping-Ansicht erneut oeffnen, um zu
// pruefen, dass die Aenderung tatsaechlich dauerhaft uebernommen wurde
// (nicht nur lokaler React-State).
await page.reload({ waitUntil: "networkidle0" });
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll("button")).find((b) => b.textContent.trim() === "Bearbeiten");
btn.click();
});
await page.waitForFunction(() => document.body.innerText.includes("Version"), { timeout: 10000 });
const reloadedValue = await page.evaluate(() => {
const label = Array.from(document.querySelectorAll("label")).find((l) => l.textContent.startsWith("kundennummer"));
if (!label) return null;
const input = document.getElementById(label.getAttribute("for"));
return input ? input.value : null;
});
if (reloadedValue !== "debitorennummer") {
fail("Mapping-Aenderung wurde nach Neuladen nicht uebernommen, gelesener Wert: " + reloadedValue);
} else {
console.log("OK: Pruefung 3 (Mapping-Aenderung uebernommen und nach Neuladen bestaetigt)");
}
} catch (err) {
fail(err.stack || String(err));
} finally {
await browser.close();
}
process.exit(process.exitCode || 0);
})();
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# API-01: startet den echten setup-a11y-fixture-Dienst gegen eine
# Wegwerf-DB, baut und startet web/connect-setup dagegen, fuehrt einen
# echten Browserlauf durch, der das Einrichtungsformular tatsaechlich
# ausfuellt und absendet (connect-setup-interaction-check.js), und
# anschliessend den axe/Layout-Audit (Muster: connect-status-run.sh).
# Env: A11Y_DSN (PostgreSQL), A11Y_CHROME (Chrome-Binary)
# Ergebnis: $OUT/report.json ; Exit 1 bei Befund
set -uo pipefail
repo="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${OUT:-/tmp/api01-a11y-out}"; mkdir -p "$OUT"
: "${A11Y_DSN:?A11Y_DSN fehlt}"
if [ -z "${A11Y_CHROME:-}" ]; then
A11Y_CHROME="$(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux64/chrome /root/.cache/puppeteer/chrome/*/chrome-linux64/chrome 2>/dev/null | tail -1)"
[ -n "$A11Y_CHROME" ] || A11Y_CHROME="$(command -v google-chrome || command -v chromium || true)"
fi
: "${A11Y_CHROME:?A11Y_CHROME fehlt und kein Chrome gefunden}"
export A11Y_CHROME
: "${A11Y_KEK:=$(head -c 32 /dev/urandom | base64)}"
API_PORT=9591
WEB_PORT=4191
PORTS="$API_PORT $WEB_PORT"
listener_pid() { ss -ltnpH "sport = :$1" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2; }
stop_servers() { for port in $PORTS; do pid="$(listener_pid "$port")"; [ -n "$pid" ] && kill "$pid" 2>/dev/null; done; sleep 1; }
trap stop_servers EXIT
for port in $PORTS; do
if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$port/"; then echo "Port $port ist belegt, Abbruch (kein fremder Prozess wird beendet)"; exit 2; fi
done
(cd "$repo/connect" && go build -o "$OUT/setup-a11y-fixture" ./cmd/setup-a11y-fixture) || exit 2
(cd "$repo/connect" && NEXARCH_CONNECT_TENANT_DSN="$A11Y_DSN" go run ./cmd/migrate) || { echo "migration fehlgeschlagen"; exit 2; }
NEXARCH_CONNECT_APP_TENANT_DSN="$A11Y_DSN" NEXARCH_CONNECT_APP_LISTEN_ADDR="127.0.0.1:$API_PORT" NEXARCH_CONNECT_APP_KEK="$A11Y_KEK" \
setsid nohup "$OUT/setup-a11y-fixture" >"$OUT/setup-app.log" 2>&1 </dev/null &
for _ in $(seq 40); do curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: acme" "http://127.0.0.1:$API_PORT/connectors/catalog" && break; sleep 0.5; done
curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: acme" "http://127.0.0.1:$API_PORT/connectors/catalog" || { echo "setup-a11y-fixture startet nicht"; cat "$OUT/setup-app.log"; exit 2; }
export NEXT_PUBLIC_CONNECT_API_URL="http://127.0.0.1:$API_PORT"
export NEXT_PUBLIC_USER_ID="u1"
export NEXT_PUBLIC_TENANT_SLUG="acme"
d=connect-setup
(cd "$repo/web/$d" && { [ -d node_modules ] || npm install --no-audit --no-fund --loglevel=error; } >/dev/null 2>&1
rm -rf node_modules/@nexarch/shl; mkdir -p node_modules/@nexarch/shl
(cd ../shl && tar cf - --exclude=node_modules --exclude=__tests__ .) | tar xf - -C node_modules/@nexarch/shl
rm -rf .next; npx next build >"$OUT/build-$d.log" 2>&1 || { echo "build $d fehlgeschlagen"; tail -20 "$OUT/build-$d.log"; exit 2; }
PORT=$WEB_PORT setsid nohup npx next start -p "$WEB_PORT" >"$OUT/serve-$d.log" 2>&1 </dev/null &
)
for _ in $(seq 60); do curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/setup" && break; sleep 0.5; done
curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/setup" || { echo "$d startet nicht"; cat "$OUT/serve-$d.log"; exit 2; }
echo "--- echte Formular-Interaktion (Pruefung 1/2/3) ---"
A11Y_WEB_URL="http://127.0.0.1:$WEB_PORT" node "$repo/web/a11y-audit/connect-setup-interaction-check.js"
interaction_rc=$?
echo "--- axe/Layout-Audit ---"
echo "http://127.0.0.1:$WEB_PORT/setup" > "$OUT/pages.txt"
A11Y_PAGES="$OUT/pages.txt" A11Y_OUT="$OUT/report.json" node "$repo/web/a11y-audit/audit.js"
audit_rc=$?
echo "--- report ---"
cat "$OUT/report.json" 2>/dev/null
if [ $interaction_rc -ne 0 ] || [ $audit_rc -ne 0 ]; then
exit 1
fi
exit 0
+1
View File
@@ -0,0 +1 @@
install-links=true
+39
View File
@@ -0,0 +1,39 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Connect einrichten",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<head>
{/* Muster aus UX-07/UX-08 (web/mail-search): sichtbare Fokusmarkierung
und Theme-Variablen vor dem ersten Paint, auch ohne @nexarch/shl-Shell. */}
<style>{focusVisibleStyles}</style>
<Script id="shl-theme-init" strategy="beforeInteractive">
{themeInitScript()}
</Script>
</head>
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/setup");
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { I18nProvider, ThemeProvider, ToastProvider } from "@nexarch/shl";
import SetupPage from "./page";
import { UnauthorizedError } from "../../lib/api";
vi.mock("../../lib/api", async () => {
const actual = await vi.importActual<typeof import("../../lib/api")>("../../lib/api");
return {
...actual,
fetchCatalog: vi.fn(),
fetchConnections: vi.fn(),
setConnection: vi.fn(),
fetchMapping: vi.fn(),
saveMapping: vi.fn(),
};
});
import { fetchCatalog, fetchConnections, setConnection, fetchMapping, saveMapping } from "../../lib/api";
const catalog = [
{
id: "erp",
display_name: "ERP-System",
credential_kind: "api_key" as const,
schema: [
{ nexarch_field: "kundennummer", required: true },
{ nexarch_field: "name", required: true },
{ nexarch_field: "email", required: false },
],
},
{
id: "crm",
display_name: "CRM-System",
credential_kind: "oauth2" as const,
schema: [{ nexarch_field: "kontakt_id", required: true }],
},
];
function renderPage() {
return render(
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<SetupPage />
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
describe("SetupPage", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("zeigt eine explizite Meldung ohne Anmeldung", async () => {
vi.mocked(fetchCatalog).mockResolvedValue(catalog);
vi.mocked(fetchConnections).mockRejectedValue(new UnauthorizedError("nicht angemeldet"));
renderPage();
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain("Anmeldung erforderlich");
});
});
// AC1/Pruefung 1: ein Testconnector laesst sich vollstaendig ueber die
// Oberflaeche einrichten (Typ waehlen, Instanz benennen, Zugangsdaten
// eingeben, speichern) — ohne Konfigurationsdatei.
it("richtet einen Testconnector vollstaendig ueber das Formular ein", async () => {
vi.mocked(fetchCatalog).mockResolvedValue(catalog);
vi.mocked(fetchConnections).mockResolvedValueOnce([]).mockResolvedValueOnce([
{
connector_type: "erp",
connector_type_label: "ERP-System",
connector_instance_id: "test-erp-1",
credential_kind: "api_key",
masked_credential: "••••6789",
updated_at: new Date().toISOString(),
},
]);
vi.mocked(setConnection).mockResolvedValue({
connector_type: "erp",
connector_type_label: "ERP-System",
connector_instance_id: "test-erp-1",
credential_kind: "api_key",
masked_credential: "••••6789",
updated_at: new Date().toISOString(),
});
renderPage();
await waitFor(() => expect(screen.getByLabelText("Instanz-Kennung")).toBeTruthy());
fireEvent.change(screen.getByLabelText("Instanz-Kennung"), { target: { value: "test-erp-1" } });
fireEvent.change(screen.getByLabelText("API-Schlüssel"), { target: { value: "geheimnis-123456789" } });
fireEvent.click(screen.getByText("Verbindung speichern"));
await waitFor(() => {
expect(setConnection).toHaveBeenCalledWith("erp", "test-erp-1", {
kind: "api_key",
api_key: "geheimnis-123456789",
access_token: undefined,
refresh_token: undefined,
});
});
await waitFor(() => {
expect(screen.getByText("test-erp-1")).toBeTruthy();
});
});
// AC2/Pruefung 2: Zugangsdaten werden in der Oberflaeche nur maskiert
// angezeigt, nie im Klartext.
it("zeigt Zugangsdaten in der Verbindungsliste nur maskiert", async () => {
vi.mocked(fetchCatalog).mockResolvedValue(catalog);
vi.mocked(fetchConnections).mockResolvedValue([
{
connector_type: "erp",
connector_type_label: "ERP-System",
connector_instance_id: "acme-erp-1",
credential_kind: "api_key",
masked_credential: "••••9999",
updated_at: new Date().toISOString(),
},
]);
renderPage();
await waitFor(() => {
expect(screen.getByText("••••9999")).toBeTruthy();
});
expect(screen.queryByText(/geheimnis/i)).toBeNull();
expect(document.body.innerHTML).not.toMatch(/api[_-]?key.{0,20}[a-zA-Z0-9]{10,}/i);
});
// AC3/Pruefung 3: eine Mapping-Aenderung ueber die Oberflaeche wird
// korrekt uebernommen.
it("uebernimmt eine Mapping-Aenderung korrekt", async () => {
vi.mocked(fetchCatalog).mockResolvedValue(catalog);
vi.mocked(fetchConnections).mockResolvedValue([
{
connector_type: "erp",
connector_type_label: "ERP-System",
connector_instance_id: "acme-erp-1",
credential_kind: "api_key",
masked_credential: "••••9999",
updated_at: new Date().toISOString(),
},
]);
vi.mocked(fetchMapping).mockResolvedValue({
schema: [
{ nexarch_field: "kundennummer", required: true },
{ nexarch_field: "name", required: true },
],
fields: [{ nexarch_field: "kundennummer", external_field: "debitorennummer" }],
version: 1,
});
vi.mocked(saveMapping).mockResolvedValue({
schema: [
{ nexarch_field: "kundennummer", required: true },
{ nexarch_field: "name", required: true },
],
fields: [
{ nexarch_field: "kundennummer", external_field: "debitorennummer" },
{ nexarch_field: "name", external_field: "firmenname" },
],
version: 2,
});
renderPage();
await waitFor(() => screen.getByText("Bearbeiten"));
fireEvent.click(screen.getByText("Bearbeiten"));
await waitFor(() => {
expect(screen.getByLabelText(/^kundennummer/)).toHaveValue("debitorennummer");
});
fireEvent.change(screen.getByLabelText(/^name/), { target: { value: "firmenname" } });
fireEvent.click(screen.getByText("Mapping speichern"));
await waitFor(() => {
expect(saveMapping).toHaveBeenCalledWith("erp", "acme-erp-1", [
{ nexarch_field: "kundennummer", external_field: "debitorennummer" },
{ nexarch_field: "name", external_field: "firmenname" },
]);
});
await waitFor(() => {
expect(screen.getByText("Version 2")).toBeTruthy();
});
});
});
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { SelectField, Table, TextField, useToast, type TableColumn } from "@nexarch/shl";
import {
ApiError,
Connection,
ConnectorType,
Entry,
MappingView,
UnauthorizedError,
fetchCatalog,
fetchConnections,
fetchMapping,
saveMapping,
setConnection,
} from "../../lib/api";
const CREDENTIAL_KIND_LABEL: Record<string, string> = {
api_key: "API-Schlüssel",
oauth2: "OAuth2-Zugangsdaten",
};
export default function SetupPage() {
const { push } = useToast();
const [connectors, setConnectors] = useState<ConnectorType[] | null>(null);
const [connections, setConnections] = useState<Connection[] | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [connectorType, setConnectorType] = useState("");
const [instanceID, setInstanceID] = useState("");
const [apiKey, setApiKey] = useState("");
const [accessToken, setAccessToken] = useState("");
const [refreshToken, setRefreshToken] = useState("");
const [saving, setSaving] = useState(false);
const [activeConnection, setActiveConnection] = useState<{ type: string; instance: string } | null>(null);
const [mapping, setMapping] = useState<MappingView | null>(null);
const [mappingFields, setMappingFields] = useState<Record<string, string>>({});
const [mappingError, setMappingError] = useState<string | null>(null);
const [savingMapping, setSavingMapping] = useState(false);
function load() {
Promise.all([fetchCatalog(), fetchConnections()])
.then(([types, conns]) => {
setConnectors(types);
setConnections(conns);
setUnauthorized(false);
if (!connectorType && types.length > 0) {
setConnectorType(types[0].id);
}
})
.catch((err) => {
if (err instanceof UnauthorizedError) {
setUnauthorized(true);
return;
}
const message = err instanceof ApiError || err instanceof Error ? err.message : "Connectoren konnten nicht geladen werden.";
setLoadError(message);
push(message, "danger");
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, []);
const selectedType = useMemo(() => connectors?.find((c) => c.id === connectorType) ?? null, [connectors, connectorType]);
function resetSecretFields() {
setApiKey("");
setAccessToken("");
setRefreshToken("");
}
async function handleCreateConnection(e: React.FormEvent) {
e.preventDefault();
if (!selectedType || !instanceID.trim()) {
push("Connector-Typ und Instanz-Kennung sind erforderlich.", "danger");
return;
}
setSaving(true);
try {
await setConnection(selectedType.id, instanceID.trim(), {
kind: selectedType.credential_kind,
api_key: selectedType.credential_kind === "api_key" ? apiKey : undefined,
access_token: selectedType.credential_kind === "oauth2" ? accessToken : undefined,
refresh_token: selectedType.credential_kind === "oauth2" ? refreshToken : undefined,
});
push("Verbindung gespeichert.", "success");
resetSecretFields();
setInstanceID("");
load();
} catch (err) {
const message = err instanceof ApiError || err instanceof Error ? err.message : "Verbindung konnte nicht gespeichert werden.";
push(message, "danger");
} finally {
setSaving(false);
}
}
function openMapping(connection: Connection) {
setActiveConnection({ type: connection.connector_type, instance: connection.connector_instance_id });
setMapping(null);
setMappingError(null);
fetchMapping(connection.connector_type, connection.connector_instance_id)
.then((m) => {
setMapping(m);
const next: Record<string, string> = {};
for (const f of m.schema) {
const existing = m.fields.find((e) => e.nexarch_field === f.nexarch_field);
next[f.nexarch_field] = existing?.external_field ?? "";
}
setMappingFields(next);
})
.catch((err) => {
const message = err instanceof ApiError || err instanceof Error ? err.message : "Mapping konnte nicht geladen werden.";
setMappingError(message);
});
}
async function handleSaveMapping(e: React.FormEvent) {
e.preventDefault();
if (!activeConnection || !mapping) return;
const fields: Entry[] = mapping.schema
.map((f) => ({ nexarch_field: f.nexarch_field, external_field: (mappingFields[f.nexarch_field] ?? "").trim() }))
.filter((e) => e.external_field !== "");
setSavingMapping(true);
setMappingError(null);
try {
const updated = await saveMapping(activeConnection.type, activeConnection.instance, fields);
setMapping(updated);
push("Feld-Mapping gespeichert.", "success");
} catch (err) {
const message = err instanceof ApiError || err instanceof Error ? err.message : "Mapping konnte nicht gespeichert werden.";
setMappingError(message);
} finally {
setSavingMapping(false);
}
}
if (unauthorized) {
return (
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">Anmeldung erforderlich, um Connectoren einzurichten.</p>
</main>
);
}
if (loadError) {
return (
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
const columns: TableColumn<Connection>[] = [
{ key: "type", header: "Connector", render: (r) => r.connector_type_label },
{ key: "instance", header: "Instanz", render: (r) => r.connector_instance_id },
{ key: "kind", header: "Art", render: (r) => CREDENTIAL_KIND_LABEL[r.credential_kind] ?? r.credential_kind },
{ key: "credential", header: "Zugangsdaten", render: (r) => <span data-masked="true">{r.masked_credential}</span> },
{ key: "updated", header: "Aktualisiert", render: (r) => new Date(r.updated_at).toLocaleString("de-DE") },
{
key: "mapping",
header: "Feld-Mapping",
render: (r) => (
<button type="button" onClick={() => openMapping(r)}>
Bearbeiten
</button>
),
},
];
return (
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
<h1>Connectoren einrichten</h1>
<section aria-labelledby="new-connection-heading" style={{ marginBottom: 32 }}>
<h2 id="new-connection-heading">Neue Verbindung</h2>
<form onSubmit={handleCreateConnection}>
<SelectField
label="Connector-Typ"
value={connectorType}
onChange={(e) => {
setConnectorType(e.target.value);
resetSecretFields();
}}
>
{(connectors ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.display_name}
</option>
))}
</SelectField>
<TextField
label="Instanz-Kennung"
value={instanceID}
onChange={(e) => setInstanceID(e.target.value)}
hint="Frei wählbarer Name dieser Verbindung, z. B. 'mandant-a-prod'."
/>
{selectedType?.credential_kind === "api_key" && (
<TextField
label="API-Schlüssel"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
)}
{selectedType?.credential_kind === "oauth2" && (
<>
<TextField
label="Access Token"
type="password"
autoComplete="off"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
/>
<TextField
label="Refresh Token"
type="password"
autoComplete="off"
value={refreshToken}
onChange={(e) => setRefreshToken(e.target.value)}
hint="Optional, ermöglicht automatische Erneuerung."
/>
</>
)}
<button type="submit" disabled={saving} style={{ marginTop: 8 }}>
Verbindung speichern
</button>
</form>
</section>
<section aria-labelledby="connections-heading">
<h2 id="connections-heading">Eingerichtete Verbindungen</h2>
{connections && connections.length === 0 && <p>Noch keine Connector-Verbindungen eingerichtet.</p>}
{connections && connections.length > 0 && (
<Table
columns={columns}
rows={connections}
rowKey={(r) => `${r.connector_type}/${r.connector_instance_id}`}
caption="Eingerichtete Connector-Verbindungen mit maskierten Zugangsdaten"
/>
)}
</section>
{activeConnection && (
<section aria-labelledby="mapping-heading" style={{ marginTop: 32 }}>
<h2 id="mapping-heading">
Feld-Mapping: {activeConnection.type} / {activeConnection.instance}
</h2>
{mappingError && <p role="alert">{mappingError}</p>}
{mapping && (
<form onSubmit={handleSaveMapping}>
{mapping.schema.map((f) => (
<TextField
key={f.nexarch_field}
label={`${f.nexarch_field}${f.required ? " (Pflichtfeld)" : ""}`}
value={mappingFields[f.nexarch_field] ?? ""}
onChange={(e) => setMappingFields((prev) => ({ ...prev, [f.nexarch_field]: e.target.value }))}
hint="Name des entsprechenden Felds im Drittsystem"
/>
))}
<p>Version {mapping.version}</p>
<button type="submit" disabled={savingMapping}>
Mapping speichern
</button>
</form>
)}
</section>
)}
</main>
);
}
+113
View File
@@ -0,0 +1,113 @@
// Duenner Client fuer API-01 (connect/internal/connectorsetupapi) — enthaelt
// keine eigene Geschaeftslogik, nur Datenabruf/-versand. Identitaet kommt
// uebergangsweise (wie bei web/connect-status vor der zentralen
// Core-JWT-Anbindung) aus Umgebungsvariablen, die als Header durchgereicht
// werden.
export class UnauthorizedError extends Error {}
export class ApiError extends Error {}
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_CONNECT_API_URL;
if (!base) {
throw new Error("NEXT_PUBLIC_CONNECT_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base;
}
function identityHeaders(): Record<string, string> {
const userID = process.env.NEXT_PUBLIC_USER_ID;
const tenant = process.env.NEXT_PUBLIC_TENANT_SLUG;
const headers: Record<string, string> = {};
if (userID) headers["X-User-Id"] = userID;
if (tenant) headers["X-Tenant-Slug"] = tenant;
return headers;
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
...identityHeaders(),
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
if (res.status === 401) {
throw new UnauthorizedError("Anmeldung erforderlich, um Connectoren einzurichten.");
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`);
}
return res.json().catch(() => undefined as T);
}
export type CredentialKind = "oauth2" | "api_key";
export interface FieldDef {
nexarch_field: string;
required: boolean;
}
export interface Entry {
nexarch_field: string;
external_field: string;
}
export interface ConnectorType {
id: string;
display_name: string;
credential_kind: CredentialKind;
schema: FieldDef[];
}
export interface Connection {
connector_type: string;
connector_type_label: string;
connector_instance_id: string;
credential_kind: CredentialKind;
masked_credential: string;
expires_at?: string;
updated_at: string;
}
export interface MappingView {
schema: FieldDef[];
fields: Entry[];
version: number;
}
export function fetchCatalog(): Promise<ConnectorType[]> {
return req("/connectors/catalog");
}
export function fetchConnections(): Promise<Connection[]> {
return req("/connectors/connections");
}
export interface SetConnectionInput {
kind: CredentialKind;
api_key?: string;
access_token?: string;
refresh_token?: string;
expires_at?: string;
}
export function setConnection(connectorType: string, instanceID: string, input: SetConnectionInput): Promise<Connection> {
return req(`/connectors/connections/${encodeURIComponent(connectorType)}/${encodeURIComponent(instanceID)}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function fetchMapping(connectorType: string, instanceID: string): Promise<MappingView> {
return req(`/connectors/connections/${encodeURIComponent(connectorType)}/${encodeURIComponent(instanceID)}/mapping`);
}
export function saveMapping(connectorType: string, instanceID: string, fields: Entry[]): Promise<MappingView> {
return req(`/connectors/connections/${encodeURIComponent(connectorType)}/${encodeURIComponent(instanceID)}/mapping`, {
method: "PUT",
body: JSON.stringify({ fields }),
});
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+3290
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "nexarch-connect-setup",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "16.3.3",
"@types/node": "^22.20.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"jsdom": "24.1.0",
"typescript": "5.5.3",
"vitest": "^5.0.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// SHL-01-Erfahrung (bfa5c61): ohne afterEach(cleanup) stapeln sich
// gerenderte DOM-Bäume zwischen Tests.
afterEach(cleanup);