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
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:
commit
7b9e6b94ce
23 files changed
+5126
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
Executable
+65
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
install-links=true
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IndexPage() {
|
||||
redirect("/setup");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3290
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user