Compare commits

..
Author SHA1 Message Date
patrickandClaude Sonnet 5.5 7515b8db4d refactor: weitere identische Pakete nach common/ (svcauth, auditclient, tenantregistryclient, policyclient, httpharden, loadtest, httpclient)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Connect-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Workflow-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Connect-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Workflow-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Mail-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Workflow-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Nur Pakete, deren Code ohne Kommentare/Importpfade byte-gleich ist:
- httpclient: ai, connect (Root behaelt eigene Kopie wegen strengerem Waechtertest)
- svcauth: archive, ai, workflow
- auditclient: ai, connect, archive
- tenantregistryclient: dms, ai, workflow (archive weicht ab, bleibt)
- policyclient: mail, connect (dms weicht ab, bleibt)
- httpharden: Root, archive (dms weicht ab, bleibt)
- loadtest: mail, ai (archive, connect weichen ab, bleiben)

go.mod (require + replace) fuer ai, connect, workflow; Pflichttest-Gates
von ai/workflow und CI-Pfadfilter um common/ erweitert, damit die
sicherheitskritischen Pakete (svcauth, tenantregistryclient) weiter
gegated werden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:06:07 +01:00
patrickandClaude Sonnet 5.5 cd5ca8da53 refactor: geteiltes Modul common/ (Pilot httpclient, apierror)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
- common/httpclient: bisher byte-identisch in dms, mail und archive
- common/apierror: bisher byte-identisch in Root und archive
- Importpfade, go.mod (require + replace), archtest-Ausnahmen und
  CI-Pfadfilter der mail-/archive-Pflichttest-Gates angepasst
- Waechtertest fuer den Standard-Client prueft weiter dms, mail, archive
- dms/internal/apierror bleibt eigenstaendig (5xx-Fix in eigenem PR)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:52:24 +01:00
435 changed files with 2076 additions and 6569 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
# common/ ist modulübergreifend: Änderungen brauchen Review (Auswirkungsliste im PR)
/common/ @patrick
+1
View File
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "ai/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "archive/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -1,23 +0,0 @@
name: common Pflichttest-Gate
on:
pull_request:
paths:
- "common/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Gate bauen
working-directory: common
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- common/ | /tmp/pflichttestgate
-33
View File
@@ -1,33 +0,0 @@
name: common Versions-Tag
on:
push:
branches: [main]
paths:
- "common/**"
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Build, Vet, Test
working-directory: common
run: |
go build ./...
go vet ./...
go test ./... -count=1
- name: Naechsten Tag setzen
run: |
minor=$(tr -d '[:space:]' < common/MINOR)
last=$(git tag -l "common/v0.${minor}.*" --sort=-v:refname | head -n1)
if [ -z "$last" ]; then next="common/v0.${minor}.0"; else
patch=${last##*.}; base=${last%.*}; next="${base}.$((patch + 1))"
fi
git tag "$next"
git push origin "$next"
-37
View File
@@ -1,37 +0,0 @@
name: DB-Integrationstests
on:
pull_request:
paths:
- "common/**"
- "archive/**"
- "ai/**"
- "connect/**"
- "scripts/ci-pg-cluster.sh"
- ".gitea/workflows/db-tests.yml"
jobs:
db-tests:
runs-on: nexarch-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Wegwerf-Postgres starten
run: scripts/ci-pg-cluster.sh start
- name: common
working-directory: common
run: go test ./... -count=1 -p 1
- name: archive
working-directory: archive
run: go test ./... -count=1 -p 1
- name: ai
working-directory: ai
run: go test ./... -count=1 -p 1
- name: connect
working-directory: connect
run: go test ./... -count=1 -p 1
- name: Wegwerf-Postgres stoppen
if: always()
run: scripts/ci-pg-cluster.sh stop
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "mail/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "workflow/**"
- "common/**"
jobs:
pflichttest-gate:
-564
View File
@@ -604,567 +604,3 @@ Keine Commits in dieser Session.
- scripts/qa02-foundation-gate.sh | 2 +-
---
## 2026-10-19 12:07 – 12:07 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/audit-log/app/page.tsx | 6 +++---
- web/dms-browser/app/layout.tsx | 6 +++---
- web/lic-admin/app/page.tsx | 18 +++++++++---------
- web/portal/app/layout.tsx | 12 ++----------
- web/portal/app/page.tsx | 4 ++--
- web/shl/components/ModuleNavShell.tsx | 18 +++++++++++++++---
- web/shl/components/Shell.tsx | 2 ++
- web/shl/index.ts | 2 +-
- web/shl/tokens/tokens.ts | 24 ++++++++++++++++++++++++
- web/status-page/app/page.tsx | 12 ++++++------
- web/tenant-admin/app/page.tsx | 8 ++++----
---
## 2026-10-19 12:08 – 12:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/audit-log/app/page.tsx | 6 +++---
- web/dms-browser/app/layout.tsx | 6 +++---
- web/lic-admin/app/page.tsx | 18 +++++++++---------
- web/portal/app/layout.tsx | 12 ++----------
- web/portal/app/page.tsx | 4 ++--
- web/shl/components/ModuleNavShell.tsx | 18 +++++++++++++++---
- web/shl/components/Shell.tsx | 2 ++
- web/shl/index.ts | 2 +-
- web/shl/tokens/tokens.ts | 24 ++++++++++++++++++++++++
- web/status-page/app/page.tsx | 12 ++++++------
- web/tenant-admin/app/page.tsx | 8 ++++----
---
## 2026-10-19 12:14 – 12:15 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/audit-log/app/page.tsx | 6 +++---
- web/dms-browser/app/layout.tsx | 6 +++---
- web/lic-admin/app/page.tsx | 18 +++++++++---------
- web/portal/app/layout.tsx | 12 ++----------
- web/portal/app/page.tsx | 4 ++--
- web/shl/components/ModuleNavShell.tsx | 18 +++++++++++++++---
- web/shl/components/Shell.tsx | 2 ++
- web/shl/index.ts | 2 +-
- web/shl/tokens/tokens.ts | 24 ++++++++++++++++++++++++
- web/status-page/app/page.tsx | 12 ++++++------
- web/tenant-admin/app/page.tsx | 8 ++++----
---
## 2026-10-19 12:29 – 12:30 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- dms/internal/upload/service.go | 38 ++++++++++++++++++++++++++++++---
- dms/internal/upload/service_test.go | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-10-19 12:31 – 12:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- dms/internal/upload/service.go | 25 ++++++++++++++++++++++++-
- dms/internal/upload/service_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-10-19 12:44 – 12:53 (8m)
**Beschreibung:** Claude Code Session
**Projekt:** banking
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- dms/internal/upload/service.go | 25 ++++++++++++++++++++++++-
- dms/internal/upload/service_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-10-19 12:54 – 12:54 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- dms/internal/upload/service.go | 25 ++++++++++++++++++++++++-
- dms/internal/upload/service_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-10-19 13:47 – 13:50 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** banking
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/mail-postfach/app/layout.tsx | 8 +++++---
- web/shl/components/ModuleNavShell.tsx | 4 ++++
---
## 2026-10-19 14:49 – 14:50 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** archivmail
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- MIGRATIONSKONZEPT.md | 2 +-
- PRODUKTARCHITEKTUR.md | 2 +-
- SICHERHEITSKONZEPT.md | 16 ++++++++--------
- SKALIERUNGSKONZEPT.md | 2 +-
- STORAGE-KONZEPT.md | 4 ++--
- dms/internal/searchsync/searchsync_test.go | 4 ++--
- web/portal/app/ModuleGrid.test.tsx | 3 ++-
- web/portal/app/ModuleGrid.tsx | 17 +----------------
- web/portal/lib/modules.test.ts | 4 +++-
- web/shl/components/ModuleNavShell.tsx | 27 +++++++++++++++++++--------
- web/shl/index.ts | 2 +-
---
## 2026-10-19 14:58 – 23:11 (8h 12m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-20 04:03 – 04:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-20 04:03 – 04:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-20 04:03 – 04:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-20 04:03 – 04:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-20 04:03 – 04:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 11:18 – 11:18 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 11:20 – 11:20 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 11:54 – 11:54 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 11:55 – 11:55 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 11:57 – 11:57 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 11:58 – 11:58 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:37 – 12:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:38 – 12:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:40 – 12:40 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:42 – 12:42 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:42 – 12:44 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:47 – 12:52 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:53 – 12:54 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 12:55 – 12:56 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 13:00 – 13:01 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** Trad-Bot
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 13:35 – 13:39 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 13:42 – 13:52 (10m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 13:53 – 13:53 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 13:54 – 14:06 (12m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 14:08 – 14:41 (32m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 15:03 – 15:25 (22m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
## 2026-10-27 15:27 – 15:43 (15m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
- web/shl/index.ts | 3 +++
---
+1 -12
View File
@@ -6,17 +6,6 @@ Stand: 2026-08-26. Zusammenführung aller bisherigen Entscheidungen aus `nexarch
NEXARCH ist die Nachfolgeplattform von **archivmail** (E-Mail-Archivierung) und **archivdms** (Dokumentenmanagement) — nicht als Umbenennung, sondern als modulare Neuentwicklung unter der Positionierung **"Next Generation Information Management"**: E-Mails, Dokumente, digitale Archivierung, OCR, Volltextsuche, Workflows, Compliance, Integrationen, KI — als ein zusammenhängendes System, nicht als zwei getrennte Werkzeuge nebeneinander.
## 1a. Vorbildsysteme
NEXARCH vereint die Stärken bestehender Systeme und vermeidet deren bekannte Schwächen (Details: `dms-kanban/known-issues-archivdms.md`, `dms-kanban/known-issues-archivmail.md`).
| Bereich | Vorbildsysteme |
|---|---|
| Mail | **archivmail** (eigenes Produkt, Vorgänger), **MailPiler** |
| DMS | **paperless-ngx**, **Alfresco**, **Docspell**, **ecoDMS**, **archivdms** (eigenes Produkt, Vorgänger) |
Weitere Vergleichsprojekte (z. B. OpenKM, Nuxeo, Mayan EDMS, Seafile) sind noch zu prüfen. Für das DMS-Modul siehe auch `dms/README.md`.
## 2. Architekturprinzip
**Ein modularer Monolith aus sieben eigenständigen, aber koordinierten Diensten** — kein Microservice-Zwang, aber klare Dienstgrenzen mit eigenem Datenmodell, eigenem Go-Binary, eigenem Kanban-Board:
@@ -75,7 +64,7 @@ Kein Modul kopiert die Daten eines anderen — stattdessen ein wiederkehrendes *
Gemeinsamer Nenner aller drei: **Referenz statt Kopie, Adapter statt Direktkopplung.**
Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete liegen deshalb im versionierten Modul `common/` (Git-Tags `common/vX.Y.Z`, siehe `common/README.md`). So bleiben Module einzeln baubar (Docker, eigenes Repo, Partner-Build). Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete werden deshalb im geteilten Modul `common/` gehalten (per `replace` in den `go.mod` eingebunden, keine eigenen Abhängigkeiten außer der Standardbibliothek). Pilot: `common/httpclient`, `common/apierror`. Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
Dasselbe Prinzip gilt auch innerhalb eines Moduls, auf Code-Ebene: Hauptdateien/Entry-Points bleiben klein, Fachlogik wandert in eigene Module. Vor einer neuen Funktions-/Modul-Datei wird geprüft, ob dieselbe Funktionalität nicht schon woanders existiert (auch modulübergreifend, z. B. in Core) — keine doppelten Funktionsdateien. Das eigene Kanban-Planungswerkzeug wurde nach genau diesem Muster korrigiert (sieben `kanban.html` enthielten identisches JS/CSS als Volltext-Duplikat, damals in `_shared/kanban.js`/`kanban.css` ausgelagert) — die Boards selbst sind inzwischen komplett aus dem Repo entfernt (Ticket-Historie liegt in der Gitea-PR-Historie), das Prinzip "keine doppelten Funktionsdateien" gilt unverändert für den eigentlichen NEXARCH-Code.
+16 -44
View File
@@ -43,7 +43,6 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient"
)
@@ -53,24 +52,13 @@ const defaultListenAddr = "127.0.0.1:9600"
const defaultModel = "default"
type config struct {
dsn string
serviceToken string
addr string
model string
moduleURLs map[string]string
coreBaseURL string
coreServiceToken string
policyURL string
policyServiceToken string
policyDisabled bool
}
// policyClient liefert nil nur mit NEXARCH_AI_APP_POLICY_DISABLED=1.
func (c config) policyClient() *policyclient.Client {
if c.policyURL == "" {
return nil
}
return policyclient.New(c.policyURL, c.policyServiceToken)
dsn string
serviceToken string
addr string
model string
moduleURLs map[string]string
coreBaseURL string
coreServiceToken string
}
// parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine
@@ -96,19 +84,13 @@ func parseModuleBaseURLs(raw string) map[string]string {
// Programmabbruchs weiter unten im Code.
func loadConfig(getenv func(string) string) (config, error) {
cfg := config{
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
model: getenv("NEXARCH_AI_APP_MODEL"),
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"),
coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"),
policyURL: getenv("NEXARCH_AI_APP_POLICY_URL"),
policyServiceToken: getenv("NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"),
policyDisabled: getenv("NEXARCH_AI_APP_POLICY_DISABLED") == "1",
}
if cfg.policyURL != "" && cfg.policyServiceToken == "" {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_AI_APP_POLICY_URL gesetzt ist")
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
model: getenv("NEXARCH_AI_APP_MODEL"),
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"),
coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"),
}
if cfg.dsn == "" {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich")
@@ -116,9 +98,6 @@ func loadConfig(getenv func(string) string) (config, error) {
if cfg.serviceToken == "" {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_SERVICE_TOKEN ist erforderlich")
}
if cfg.policyURL == "" && !cfg.policyDisabled {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_URL ist erforderlich (oder NEXARCH_AI_APP_POLICY_DISABLED=1 als Uebergang)")
}
if cfg.addr == "" {
cfg.addr = defaultListenAddr
}
@@ -150,11 +129,7 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
).RegisterRoutes(mux)
searchSvc := semanticsearch.New(prov, vectorindex.New(pool), cfg.model)
policy := cfg.policyClient()
if policy == nil {
log.Print("ai-app: WARNUNG: Rechtepruefung ai.search.read deaktiviert (NEXARCH_AI_APP_POLICY_DISABLED=1)")
}
semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).WithPolicy(policy).RegisterRoutes(mux)
semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).RegisterRoutes(mux)
chunkStore := vectorindex.New(pool)
docchatapi.NewServer(
@@ -202,9 +177,6 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
return wiring{mux: mux, queue: q, worker: worker, quotaStore: quotaStore}
}
// Muss ueber dem Provider-Timeout (120s) liegen, sonst kappt der Server langsame Inferenz-Antworten.
const serverWriteTimeout = 130 * time.Second
func main() {
cfg, err := loadConfig(os.Getenv)
if err != nil {
@@ -229,7 +201,7 @@ func main() {
Handler: w.mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: serverWriteTimeout,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
+2 -49
View File
@@ -17,7 +17,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
// AK3 + AK4: eine fehlende Pflicht-Umgebungsvariable liefert einen klaren
@@ -44,36 +44,10 @@ func TestLoadConfig_MissingRequiredVars(t *testing.T) {
}
}
// Issue #369: Policy-URL ohne Service-Token darf nicht still ohne
// Rechtepruefung starten.
func TestLoadConfig_PolicyURLRequiresServiceToken(t *testing.T) {
func TestLoadConfig_Defaults(t *testing.T) {
env := map[string]string{
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
"NEXARCH_AI_APP_POLICY_URL": "http://127.0.0.1:8093",
}
get := func(k string) string { return env[k] }
if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_APP_POLICY_SERVICE_TOKEN") {
t.Fatalf("erwartete fehler zur fehlenden NEXARCH_AI_APP_POLICY_SERVICE_TOKEN, bekam %v", err)
}
env["NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"] = "policy-tok"
cfg, err := loadConfig(get)
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
if cfg.policyClient() == nil {
t.Fatal("policy-client muss bei gesetzter URL aktiv sein")
}
if (config{}).policyClient() != nil {
t.Fatal("ohne URL darf kein policy-client entstehen")
}
}
func TestLoadConfig_Defaults(t *testing.T) {
env := map[string]string{
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
"NEXARCH_AI_APP_POLICY_DISABLED": "1",
}
cfg, err := loadConfig(func(k string) string { return env[k] })
if err != nil {
@@ -87,12 +61,6 @@ func TestLoadConfig_Defaults(t *testing.T) {
}
}
func TestServerWriteTimeoutCoversProviderTimeout(t *testing.T) {
if serverWriteTimeout <= 120*time.Second {
t.Fatalf("serverWriteTimeout = %v, muss ueber dem Provider-Timeout von 120s liegen", serverWriteTimeout)
}
}
func newTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
@@ -216,18 +184,3 @@ func TestBuild_QueueWorkerProcessesJobWithQuota(t *testing.T) {
t.Errorf("quota-zaehlerstand = %d, want 1 (GOV-04-Verbrauchszaehlung nicht verdrahtet)", value)
}
}
func TestLoadConfig_PolicyRequiredUnlessDisabled(t *testing.T) {
env := map[string]string{
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
}
get := func(k string) string { return env[k] }
if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_APP_POLICY_URL") {
t.Fatalf("erwartete fehler zur fehlenden NEXARCH_AI_APP_POLICY_URL, bekam %v", err)
}
env["NEXARCH_AI_APP_POLICY_DISABLED"] = "1"
if _, err := loadConfig(get); err != nil {
t.Fatalf("mit POLICY_DISABLED=1 darf der Start nicht scheitern: %v", err)
}
}
+1 -2
View File
@@ -19,7 +19,7 @@ import (
)
const (
fixtureTenant = "beispiel-mandant"
fixtureTenant = "beispiel-mandant"
fixtureSourceType = "dms_document"
fixtureSourceID = "beispiel-dokument-1"
)
@@ -77,6 +77,5 @@ func main() {
docchatapi.NewServer(chatSvc, "test-token").RegisterRoutes(mux)
log.Println("searchchatapi-mockserver listening on 127.0.0.1:19603")
// archtest:allow Einmaliges lokales pa11y-Testwerkzeug, kein Produktdienst
log.Fatal(http.ListenAndServe("127.0.0.1:19603", mux))
}
+5 -4
View File
@@ -4,10 +4,7 @@ go 1.25.0
toolchain go1.25.13
require (
gitea.perlbach24.de/scripte/nexarch/common v0.2.0
github.com/jackc/pgx/v5 v5.11.0
)
require github.com/jackc/pgx/v5 v5.11.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -16,3 +13,7 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
-2
View File
@@ -1,5 +1,3 @@
gitea.perlbach24.de/scripte/nexarch/common v0.2.0 h1:gcOjoOvUkT0Zmqq3m7WQp1MfXSgmzrGTF2E/+NkO2bw=
gitea.perlbach24.de/scripte/nexarch/common v0.2.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newStore(t *testing.T) *Store {
@@ -9,7 +9,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
// --- Vertrag 1: GOV-01 registrierter Objekttyp (moduletype.ObjectType) ---
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newRecorder(t *testing.T) *Recorder {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/classify"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
"github.com/jackc/pgx/v5/pgxpool"
)
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newPool(t *testing.T) *pgxpool.Pool {
@@ -13,7 +13,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newMappingRegistry(t *testing.T) *extractmapping.Registry {
@@ -10,7 +10,7 @@ import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newRegistry(t *testing.T) *Registry {
@@ -81,7 +81,7 @@ func TestPush_UnknownFieldKey_ReportedNotDropped(t *testing.T) {
c := New(srv.URL, "svc-token-123")
result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{
"rechnungsdatum": "2026-01-15",
"rechnungsdatum": "2026-01-15",
"nicht_existent_feld": "wert",
})
if err != nil {
+205 -11
View File
@@ -1,27 +1,221 @@
// Package migrate bindet die gemeinsame Migrationslogik aus common an den
// modulspezifischen Fortschrittstabellennamen.
// Package migrate implements versioned, revertible SQL migrations (no ORM) — a
// standalone copy of the Core/DMS pattern, since AI is its own Go module
// and cannot import other modules' internal/ packages.
package migrate
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
cmigrate "gitea.perlbach24.de/scripte/nexarch/common/migrate"
)
type Migration = cmigrate.Migration
// Migration ist eine einzelne versionierte Migrationsdatei.
type Migration struct {
Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents"
UpSQL string
DownSQL string
}
// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach
// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert).
func Load(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
continue
}
versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql"))
}
sort.Strings(versions)
migrations := make([]Migration, 0, len(versions))
for _, v := range versions {
up, err := os.ReadFile(filepath.Join(dir, v+".up.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err)
}
down, err := os.ReadFile(filepath.Join(dir, v+".down.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err)
}
migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)})
}
return migrations, nil
}
// TrackingTable ist modulspezifisch benannt, damit sich Module in
// derselben Datenbank/demselben Schema nicht gegenseitig den
// Migrationsfortschritt ueberschreiben (FDN-13).
const TrackingTable = "connect_schema_migrations"
var migrator = cmigrate.MustNew(TrackingTable)
const legacyTrackingTable = "schema_migrations"
func Load(dir string) ([]Migration, error) { return cmigrate.Load(dir) }
// ensureTrackingTable legt TrackingTable an. Beim erstmaligen Anlegen wird
// ein vorhandener Bestand aus der frueheren, unqualifizierten
// schema_migrations uebernommen (nur Versionen, die dieses Modul kennt, und
// nur wenn dessen Basisversion dort steht — eine schema_migrations eines
// anderen Moduls oder eines anderen Schemas wird weder gelesen noch
// veraendert). Die alte Tabelle
// bleibt unangetastet.
func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) ([]string, error) {
return migrator.Up(ctx, pool, migrations)
// Serialisiert gleichzeitige Erstanlage/Uebernahme (mehrere App-Instanzen).
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('connect_schema_migrations'))`); err != nil {
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
}
var existed bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, TrackingTable).Scan(&existed); err != nil {
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
}
if existed {
return tx.Commit(ctx)
}
if _, err := tx.Exec(ctx, `
CREATE TABLE `+TrackingTable+` (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
}
if len(migrations) > 0 {
var legacy bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, legacyTrackingTable).Scan(&legacy); err != nil {
return fmt.Errorf("%s pruefen: %w", legacyTrackingTable, err)
}
if legacy {
versions := make([]string, len(migrations))
for i, m := range migrations {
versions[i] = m.Version
}
var hasBase bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM `+legacyTrackingTable+` WHERE version = $1)`, versions[0],
).Scan(&hasBase); err != nil {
return fmt.Errorf("%s lesen: %w", legacyTrackingTable, err)
}
if hasBase {
if _, err := tx.Exec(ctx, `
INSERT INTO `+TrackingTable+` (version, applied_at)
SELECT version, applied_at FROM `+legacyTrackingTable+` WHERE version = ANY($1)
`, versions); err != nil {
return fmt.Errorf("bestand aus %s uebernehmen: %w", legacyTrackingTable, err)
}
}
}
}
return tx.Commit(ctx)
}
func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (string, error) {
return migrator.DownOne(ctx, pool, migrations)
func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
rows, err := pool.Query(ctx, `SELECT version FROM `+TrackingTable)
if err != nil {
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
}
defer rows.Close()
applied := map[string]bool{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("migrationsversion lesen: %w", err)
}
applied[v] = true
}
return applied, rows.Err()
}
// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
// DB funktioniert (Pruefung 1).
func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
if err := ensureTrackingTable(ctx, pool, migrations); err != nil {
return nil, err
}
already, err := appliedVersions(ctx, pool)
if err != nil {
return nil, err
}
for _, m := range migrations {
if already[m.Version] {
continue
}
tx, err := pool.Begin(ctx)
if err != nil {
return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, m.UpSQL); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO `+TrackingTable+` (version) VALUES ($1)`, m.Version); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return applied, fmt.Errorf("migration %q committen: %w", m.Version, err)
}
applied = append(applied, m.Version)
}
return applied, nil
}
// DownOne macht die zuletzt angewendete Migration rueckgaengig
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
// oder "" falls keine Migration angewendet war.
func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
if err := ensureTrackingTable(ctx, pool, migrations); err != nil {
return "", err
}
already, err := appliedVersions(ctx, pool)
if err != nil {
return "", err
}
var last *Migration
for i := len(migrations) - 1; i >= 0; i-- {
if already[migrations[i].Version] {
last = &migrations[i]
break
}
}
if last == nil {
return "", nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, last.DownSQL); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, `DELETE FROM `+TrackingTable+` WHERE version = $1`, last.Version); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, TrackingTable, err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
}
return last.Version, nil
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
type fakeLister struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
// fakeVerifier simuliert Core TEN-01 fuer die Registry-Tests, ohne einen
@@ -12,7 +12,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
type fakeVerifier struct{ validTenants map[string]bool }
+2
View File
@@ -17,6 +17,8 @@ import (
// sensitivePathPatterns beschreibt die Bereiche aus
// TESTSTRATEGIE-AI.md Abschnitt 3.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)ai/internal/svcauth/`),
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)ai/internal/tenantfeature/`),
regexp.MustCompile(`(^|/)ai/internal/semanticsearch/`),
regexp.MustCompile(`(^|/)ai/internal/semanticsearchapi/`),
+5 -5
View File
@@ -1,24 +1,24 @@
// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit
// geänderter ai/internal/tenantfeature/tenantfeature.go ohne begleitende Testdatei
// geänderter ai/internal/svcauth/svcauth.go ohne begleitende Testdatei
// muss als Verstoß erkannt werden.
package pflichttestgate
import "testing"
func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) {
violations := CheckDiff([]string{"ai/internal/tenantfeature/tenantfeature.go"})
violations := CheckDiff([]string{"ai/internal/svcauth/svcauth.go"})
if len(violations) != 1 {
t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations)
}
if violations[0].Package != "ai/internal/tenantfeature" {
if violations[0].Package != "ai/internal/svcauth" {
t.Fatalf("falsches package gemeldet: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) {
violations := CheckDiff([]string{
"ai/internal/tenantfeature/tenantfeature.go",
"ai/internal/tenantfeature/tenantfeature_test.go",
"ai/internal/svcauth/svcauth.go",
"ai/internal/svcauth/svcauth_test.go",
})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße, habe: %+v", violations)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newStore(t *testing.T) *Store {
@@ -1,10 +1,10 @@
// Package classification ist das Prüfgate für QA-03: misst Klassifizierungs- und
// Package qa03gate ist das Prüfgate für QA-03: misst Klassifizierungs- und
// Extraktionsgenauigkeit am Referenzkorpus (QA-01, ai/internal/refcorpus)
// und belegt die Wirkung von Korrektur-Feedback auf künftige Vorschläge.
// Reines Prüfgate — ruft nur bestehende, bereits gemergte Pakete auf
// (classify CLS-02, extract EXT-05-Grundlage, correction/historysuggest
// CLS-05), ohne deren Code zu ändern.
package classification
package qa03gate
import (
"context"
@@ -1,4 +1,4 @@
// Package searchquality ist das Prüfgate für QA-04: es misst Suchqualität,
// Package qa04gate ist das Prüfgate für QA-04: es misst Suchqualität,
// RAG-Antwortgüte und Quellenverweis-Nachvollziehbarkeit auf einem festen,
// synthetischen Testkorpus (SEM-01..06 bereits gemergt) und ändert keines der
// geprüften Pakete. Der Korpus stammt aus refcorpus (QA-01), die "Einbettung"
@@ -7,7 +7,7 @@
// einem externen Modell abhängt. Reine Termfrequenz ohne IDF-Gewichtung wurde
// probiert und verworfen: seltene, fachlich entscheidende Wörter (z.B.
// "Gesamtbetrag") gingen gegen häufige, generische Wörter unter.
package searchquality
package qa04gate
import (
"context"
@@ -231,3 +231,4 @@ func (p *embedProvider) Chat(ctx context.Context, req provider.ChatRequest) (pro
func (p *embedProvider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
return provider.CompletionResponse{}, errors.New("qa04gate: Complete wird vom Prüfgate nicht genutzt")
}
@@ -1,4 +1,4 @@
package searchquality
package qa04gate
import (
"context"
@@ -1,4 +1,4 @@
package searchquality
package qa04gate
// testQuestion ist eine feste Testfrage des QA-04-Prüfgates gegen den
// refcorpus-Testkorpus (QA-01). erwarteteSourceID ist leer, wenn die Frage
@@ -1,9 +1,9 @@
// Package governance ist das Prüfgate für QA-05 (Datenschutz- &
// Package qa05gate ist das Prüfgate für QA-05 (Datenschutz- &
// Governance-Prüfung). Es bringt keine neue Fachlogik ein, sondern führt
// GOV-02/GOV-03/GOV-05 end-to-end gegen eine echte Postgres-Wegwerf-DB bzw.
// einen Mock-Core zusammen, um die drei QA-05-Akzeptanzkriterien in der
// Praxis (nicht nur auf Store-Ebene) nachzuweisen.
package governance
package qa05gate
import (
"context"
@@ -23,8 +23,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantfeature"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
)
// realPool liefert einen frischen, migrierten Pool auf der Wegwerf-DB aus
@@ -36,7 +36,7 @@ func realPool(t *testing.T) *pgxpool.Pool {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
pool := testdb.Pool(t, dsn)
migrationsDir, err := filepath.Abs("../../../migrations")
migrationsDir, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newPool(t *testing.T) *pgxpool.Pool {
+3 -3
View File
@@ -16,9 +16,9 @@ import (
// eine Zeitueberschreitung real simulieren laesst (respektiert ctx wie ein
// echter HTTP-Client es tun wuerde).
type slowProvider struct {
delay time.Duration
calls atomic.Int64
cancelled atomic.Int64
delay time.Duration
calls atomic.Int64
cancelled atomic.Int64
}
func (s *slowProvider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newPool(t *testing.T) *pgxpool.Pool {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func setup(t *testing.T) *residency.Store {
@@ -13,7 +13,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residencyapi"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
const testToken = "test-service-token"
@@ -8,7 +8,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newStore(t *testing.T) *Store {
@@ -11,7 +11,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingfeedback"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
const testToken = "test-service-token"
@@ -11,8 +11,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
)
func newRealPool(t *testing.T) *pgxpool.Pool {
@@ -1,8 +1,7 @@
// Package semanticsearchapi ist die HTTP-Bindung der semantischen Suche
// (SEM-03). Aufrufer ist das anfragende Modul (DMS/Mail), nicht der
// Endnutzer direkt — Service-Token-Pflicht wie moduleadapter (Workflow
// ENG-02), fail closed. Zusaetzlich (Issue #369) Rechtepruefung
// ai.search.read gegen Core fuer den Mandanten aus dem Anfrage-Koerper.
// ENG-02), fail closed.
package semanticsearchapi
import (
@@ -11,30 +10,18 @@ import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
const permissionSearchRead = "ai.search.read"
type Server struct {
svc *semanticsearch.Service
serviceToken string
policy *policyclient.Client
}
func NewServer(svc *semanticsearch.Service, serviceToken string) *Server {
return &Server{svc: svc, serviceToken: serviceToken}
}
// WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize).
// Rolle ist fest "user", weil der Modul-zu-Modul-Aufruf keine Nutzerrolle
// transportiert (Uebergang, gleiches Muster wie connect/internal/statusapi).
func (s *Server) WithPolicy(client *policyclient.Client) *Server {
s.policy = client
return s
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /search", s.requireAuth(s.handleSearch))
}
@@ -50,22 +37,6 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
}
}
func (s *Server) permitted(w http.ResponseWriter, r *http.Request, tenantSlug, permission string) bool {
if s.policy == nil {
return true
}
allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
return false
}
if !allowed {
writeError(w, http.StatusForbidden, "keine berechtigung")
return false
}
return true
}
type refRequest struct {
SourceType string `json:"source_type"`
SourceID string `json:"source_id"`
@@ -88,9 +59,6 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
if !s.permitted(w, r, req.TenantSlug, permissionSearchRead) {
return
}
refs := make([]semanticsearch.Ref, 0, len(req.AllowedRefs))
for _, ref := range req.AllowedRefs {
@@ -1,100 +0,0 @@
package semanticsearchapi_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
)
// Steht fuer Core's POST /authorize-Vertrag ({"allowed": bool}); prueft
// die echte Kette requireAuth -> permitted -> policyclient -> 403/200.
func newFakePolicyAPI(t *testing.T, allow bool, gotBody *map[string]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/authorize" || r.Header.Get("X-Service-Token") == "" {
t.Fatalf("unerwartete anfrage: %s %s", r.Method, r.URL.Path)
}
if gotBody != nil {
_ = json.NewDecoder(r.Body).Decode(gotBody)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow})
}))
}
func searchRequest(t *testing.T) *http.Request {
t.Helper()
body, _ := json.Marshal(map[string]any{
"tenant_slug": "acme",
"query": "test",
"top_k": 5,
"allowed_refs": []map[string]string{{"source_type": "dms_document", "source_id": "doc-1"}},
})
req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
return req
}
func TestHandleSearch_PolicyDenies_Rejected(t *testing.T) {
var got map[string]string
policyAPI := newFakePolicyAPI(t, false, &got)
defer policyAPI.Close()
mux := http.NewServeMux()
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, searchRequest(t))
if rec.Code != http.StatusForbidden {
t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", rec.Code, rec.Body.String())
}
if got["permission"] != "ai.search.read" || got["tenant"] != "acme" {
t.Fatalf("falsche policy-anfrage: %+v", got)
}
}
func TestHandleSearch_PolicyAllows_Passes(t *testing.T) {
policyAPI := newFakePolicyAPI(t, true, nil)
defer policyAPI.Close()
mux := http.NewServeMux()
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, searchRequest(t))
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleSearch_PolicyUnavailable_FailsClosed(t *testing.T) {
policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer policyAPI.Close()
mux := http.NewServeMux()
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, searchRequest(t))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("erwartet 503 bei ausfall des berechtigungsdienstes, bekam %d", rec.Code)
}
}
func TestHandleSearch_PolicyCheckedOnlyAfterServiceToken(t *testing.T) {
policyAPI := newFakePolicyAPI(t, true, nil)
defer policyAPI.Close()
mux := http.NewServeMux()
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
req := searchRequest(t)
req.Header.Set("X-Service-Token", "falsch")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401 bei falschem service-token, bekam %d", rec.Code)
}
}
+3 -3
View File
@@ -125,10 +125,10 @@ func wordSet(s string) map[string]bool {
// TypeMetrics fasst die Bewertungen aller Stichproben eines
// Zusammenfassungstyps zusammen (AK1).
type TypeMetrics struct {
Type string
Count int
Type string
Count int
AverageGroundedness float64
FlaggedCount int
FlaggedCount int
}
// AggregateByType berechnet je Zusammenfassungstyp die Kennzahl über alle
@@ -27,6 +27,7 @@ func (s *stubProvider) Embed(ctx context.Context, req provider.EmbeddingRequest)
return provider.EmbeddingResponse{}, nil
}
// AK1: Konfidenz wird je Vorschlag mitgeliefert.
func TestSuggest_ReturnsConfidencePerSuggestion(t *testing.T) {
p := &stubProvider{response: `[{"tag":"rechnung","confidence":0.9,"reasoning":"enthaelt rechnungsnummer"},{"tag":"steuer","confidence":0.4,"reasoning":"erwaehnt umsatzsteuer"}]`}
@@ -7,7 +7,7 @@ import (
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
func newStore(t *testing.T) *Store {
@@ -7,7 +7,6 @@ package testdb
import (
"context"
"fmt"
"os"
"sync/atomic"
"testing"
"time"
@@ -17,21 +16,6 @@ import (
var seq atomic.Int64
// AdminDSN liefert TEST_ADMIN_DSN. Fehlt er, wird uebersprungen; mit
// REQUIRE_DB=1 (CI) zaehlt das Fehlen als Fehler.
func AdminDSN(t testing.TB) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn != "" {
return dsn
}
if os.Getenv("REQUIRE_DB") == "1" {
t.Fatal("testdb: TEST_ADMIN_DSN nicht gesetzt, aber REQUIRE_DB=1")
}
t.Skip("testdb: TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
return ""
}
// Pool legt ein Schema an und liefert einen darauf ausgerichteten Pool;
// beides wird per t.Cleanup wieder entfernt.
func Pool(t testing.TB, dsn string) *pgxpool.Pool {
@@ -30,9 +30,9 @@ type Thread struct {
// Result ist die Zusammenfassung eines Threads.
type Result struct {
Summary string
MessageCount int
LastMessageID string
Summary string
MessageCount int
LastMessageID string
}
// Options steuert die Zusammenfassung.
+1 -1
View File
@@ -11,8 +11,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
)
func newPool(t *testing.T) *pgxpool.Pool {
+1 -1
View File
@@ -15,8 +15,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/archive/internal/legalholdapi"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/tenantregistryclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
"gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient"
)
func main() {
+1 -1
View File
@@ -30,7 +30,7 @@ Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche
- **WORM-Durchsetzung** (`archive/internal/wormstore/`) — die Storage-Object-Lock-Garantie selbst.
- **Zugriffsprüfung** (`archive/internal/rbacclient/`, `archive/internal/retentionapi/`) — RBAC-Anbindung
und die APIs, die sie durchsetzen.
- **Tenant-/Schlüssel-Bezug** (`archive/internal/kekclient/`,
- **Tenant-/Schlüssel-Bezug** (`archive/internal/tenantregistryclient/`, `archive/internal/kekclient/`,
`archive/internal/backupcrypto/`) — unabhängige Tenant-Validierung und Verschlüsselungspfade.
Regel: **jede geänderte `.go`-Datei in einem dieser Bereiche muss von einer geänderten oder neuen
+5 -4
View File
@@ -4,10 +4,7 @@ go 1.25.0
toolchain go1.25.13
require (
gitea.perlbach24.de/scripte/nexarch/common v0.1.0
github.com/jackc/pgx/v5 v5.9.2
)
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -16,3 +13,7 @@ require (
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
-2
View File
@@ -1,5 +1,3 @@
gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY=
gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -1,51 +0,0 @@
package archtest
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber
// den Standard-Client ohne Zeitlimit.
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for i, line := range strings.Split(string(data), "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") {
continue
}
if forbidden.MatchString(line) {
rel, _ := filepath.Rel(root, path)
t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ func moduleRoot(t *testing.T) string {
// Antwort bzw. der rohe ListenAndServe-Aufruf genau die erlaubte
// Implementierung der Regel selbst ist (API-20/API-21), kein Verstoss.
// Das Archive-Modul hat keine weiteren, physisch verschachtelten Go-Module.
var exempt = []string{}
var exempt = []string{"internal/httpharden"}
// TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein
// err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror.
+1
View File
@@ -15,6 +15,7 @@ import (
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)archive/internal/wormstore/`),
regexp.MustCompile(`(^|/)archive/internal/rbacclient/`),
regexp.MustCompile(`(^|/)archive/internal/tenantregistryclient/`),
regexp.MustCompile(`(^|/)archive/internal/retentionapi/`),
regexp.MustCompile(`(^|/)archive/internal/kekclient/`),
regexp.MustCompile(`(^|/)archive/internal/backupcrypto/`),
@@ -60,6 +60,7 @@ func TestCheckDiff_CoversAllSensitiveAreas(t *testing.T) {
cases := []string{
"archive/internal/wormstore/wormstore.go",
"archive/internal/rbacclient/rbacclient.go",
"archive/internal/tenantregistryclient/tenantregistryclient.go",
"archive/internal/retentionapi/handler.go",
"archive/internal/kekclient/kekclient.go",
"archive/internal/backupcrypto/reposecret.go",
+1 -1
View File
@@ -1,7 +1,7 @@
// Package retentionverify implementiert VAL-03: Stichprobenvergleich von
// retain_until (Quelle aus TAR-01 vs. nach der Migration im generischen
// Retention-Objektmodell) je Tenant, nach dem Berichtsmuster aus VAL-01
// (internal/migration/verify): Abweichung ist entweder erklaert oder
// (internal/migrationverify): Abweichung ist entweder erklaert oder
// blockierender Befund, nie reine Notiz.
package retentionverify
@@ -0,0 +1,60 @@
// Package tenantregistryclient ist ein schlanker HTTP-Client fuer Core
// TEN-01 (Tenant-Registry). Archive ist ein physisch getrenntes Go-Modul
// und kann Core TEN-01 daher nicht direkt importieren - nur ueber dessen
// HTTP-Schnittstelle abfragen. Gleiches Muster wie archive/internal/
// rbacclient (RET-08) und archive/internal/notifyclient (RET-07).
package tenantregistryclient
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
"net/url"
)
// Client fragt Core TEN-01 nach der Gueltigkeit eines Tenant-Kontexts.
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken}
}
// VerifyTenant prueft, ob tenantSlug in der Core-Tenant-Registry als
// existierender Tenant gefuehrt wird - unabhaengig davon, was das
// aufrufende Modul (DMS/Mail) selbst behauptet. Ein Transport-/
// Serverfehler wird als Fehler zurueckgegeben, NICHT stillschweigend als
// gueltig behandelt - der Aufrufer muss fail-closed (ablehnen) reagieren.
func (c *Client) VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/tenants/"+url.PathEscape(tenantSlug), nil)
if err != nil {
return false, fmt.Errorf("tenantregistryclient: request bauen: %w", err)
}
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.httpClient().Do(req)
if err != nil {
return false, fmt.Errorf("tenantregistryclient: aufruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("tenantregistryclient: unerwarteter status %d", resp.StatusCode)
}
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return httpclient.Default()
}
@@ -0,0 +1,59 @@
package tenantregistryclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestVerifyTenant_ExistingTenant(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tenants/mandant-a" {
t.Errorf("pfad = %q, want /tenants/mandant-a", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
if err != nil {
t.Fatalf("verifytenant: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen tenant, habe false")
}
}
func TestVerifyTenant_UnknownTenant(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "unbekannt")
if err != nil {
t.Fatalf("verifytenant: %v", err)
}
if ok {
t.Fatal("erwartet ungueltigen tenant, habe true")
}
}
func TestVerifyTenant_ServerErrorIsFailClosed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
if err == nil {
t.Fatal("erwartet fehler bei 500-antwort der tenant-registry")
}
if ok {
t.Fatal("erwartet false bei fehler (fail-closed)")
}
}
-49
View File
@@ -1,49 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
const exitPermanentDBError = 2
type pinger interface {
Ping(ctx context.Context) error
}
func isPermanentDBError(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
switch pgErr.Code {
case "28000", "28P01", "3D000":
return true
}
return false
}
func waitForDB(ctx context.Context, p pinger, timeout time.Duration, sleep func(time.Duration)) error {
deadline := time.Now().Add(timeout)
delay := time.Second
for {
err := p.Ping(ctx)
if err == nil {
return nil
}
if isPermanentDBError(err) {
return fmt.Errorf("datenbank lehnt Anmeldung oder Datenbank ab (dauerhaft): %w", err)
}
if time.Now().Add(delay).After(deadline) {
return fmt.Errorf("datenbank nach %s nicht erreichbar: %w", timeout, err)
}
sleep(delay)
if delay < 30*time.Second {
delay *= 2
}
}
}
-66
View File
@@ -1,66 +0,0 @@
package main
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
type fakePinger struct {
errs []error
calls int
}
func (f *fakePinger) Ping(context.Context) error {
i := f.calls
f.calls++
if i < len(f.errs) {
return f.errs[i]
}
return nil
}
func TestWaitForDB_PermanentAuthErrorStopsImmediately(t *testing.T) {
p := &fakePinger{errs: []error{&pgconn.PgError{Code: "28P01"}, nil}}
sleeps := 0
err := waitForDB(context.Background(), p, time.Minute, func(time.Duration) { sleeps++ })
if err == nil || !isPermanentDBError(err) {
t.Fatalf("erwartet dauerhaften fehler, war %v", err)
}
if p.calls != 1 || sleeps != 0 {
t.Fatalf("kein retry erwartet, calls=%d sleeps=%d", p.calls, sleeps)
}
}
func TestWaitForDB_TransientErrorRetriesWithBackoff(t *testing.T) {
p := &fakePinger{errs: []error{errors.New("connection refused"), errors.New("connection refused")}}
var delays []time.Duration
if err := waitForDB(context.Background(), p, time.Minute, func(d time.Duration) { delays = append(delays, d) }); err != nil {
t.Fatal(err)
}
if p.calls != 3 || len(delays) != 2 || delays[1] <= delays[0] {
t.Fatalf("calls=%d delays=%v", p.calls, delays)
}
}
func TestWaitForDB_GivesUpAfterTimeout(t *testing.T) {
p := &fakePinger{errs: []error{errors.New("x"), errors.New("x"), errors.New("x"), errors.New("x")}}
err := waitForDB(context.Background(), p, 0, func(time.Duration) {})
if err == nil || isPermanentDBError(err) {
t.Fatalf("erwartet transienten timeout-fehler, war %v", err)
}
}
func TestIsPermanentDBError(t *testing.T) {
for code, want := range map[string]bool{"28P01": true, "28000": true, "3D000": true, "57P03": false} {
if got := isPermanentDBError(&pgconn.PgError{Code: code}); got != want {
t.Errorf("%s: got %v", code, got)
}
}
if isPermanentDBError(errors.New("connection refused")) {
t.Error("verbindungsfehler ist transient")
}
}
-11
View File
@@ -82,17 +82,6 @@ func main() {
}
defer pool.Close()
dbWait := time.Duration(envInt("NEXARCH_ACCOUNT_DB_STARTUP_TIMEOUT_SECONDS", 120, 0, 3600)) * time.Second
if err := waitForDB(ctx, pool, dbWait, time.Sleep); err != nil {
log.Printf("account-api: %v — DSN in NEXARCH_ACCOUNT_TENANT_DSN (Rolle, Passwort, Datenbank) pruefen", err)
if isPermanentDBError(err) {
pool.Close()
os.Exit(exitPermanentDBError)
}
pool.Close()
os.Exit(1)
}
totpCipher, err := newTOTPCipher()
if err != nil {
log.Fatalf("totp-verschluesselung: %v", err)
+2 -2
View File
@@ -1,6 +1,6 @@
// cor01-tenant-migrate fuehrt COR-01 aus: Uebernahme der freigegebenen
// tenant_migration_map (MAP-02) in Core TEN-01. Einmalig wiederholbar
// gegen dieselbe Quelle (siehe internal/migration/tenant).
// gegen dieselbe Quelle (siehe internal/migrationtenant).
package main
import (
@@ -9,7 +9,7 @@ import (
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/migration/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationtenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
+2 -2
View File
@@ -1,6 +1,6 @@
// cor02-user-migrate fuehrt COR-02 aus: Uebernahme der freigegebenen
// user_migration_map in Core IAM-01, bcrypt-Hashes 1:1 ohne Rehash.
// Einmalig wiederholbar gegen dieselbe Quelle (siehe internal/migration/user).
// Einmalig wiederholbar gegen dieselbe Quelle (siehe internal/migrationuser).
package main
import (
@@ -9,7 +9,7 @@ import (
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/migration/user"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationuser"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
+2 -2
View File
@@ -4,7 +4,7 @@
// Migrationsschritt.
//
// Benutzer-Quellzahl kommt aus der freigegebenen user_migration_map
// (Registry-DB, siehe internal/migration/user). Dokument-/Nachrichten-
// (Registry-DB, siehe internal/migrationuser). Dokument-/Nachrichten-
// Quellzahlen kommen aus archivdms/archivmail und liegen in diesem
// Repository nicht als Go-Code vor (nur als Source-Interface in
// dms/internal/migrationdocument bzw. mail/internal/messagemigration,
@@ -22,7 +22,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/migration/verify"
"gitea.perlbach24.de/scripte/nexarch/internal/migrationverify"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
-1
View File
@@ -1 +0,0 @@
2
-26
View File
@@ -1,26 +0,0 @@
# common
Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar).
Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `bearerauth`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`.
## Einbinden
```
go env -w GOPRIVATE=gitea.perlbach24.de
go get gitea.perlbach24.de/scripte/nexarch/common@latest
```
Build-Orte brauchen Lesezugriff auf Gitea: Read-only-Token in `~/.netrc` (`machine gitea.perlbach24.de login <user> password <token>`).
## Versionen
Tags `common/v0.X.Y` setzt `.gitea/workflows/common-tag.yml` nach jedem Merge in `main`, der `common/` ändert. Module aktualisieren einzeln (`go get ...common@v0.X.Y`). Lokal mit mehreren Modulen gleichzeitig arbeiten: nicht eingechecktes `go.work`.
## Regeln
- Nur Code, der in mindestens zwei Modulen byte-gleich (ohne Kommentare) gebraucht wird; fachlich verschiedene Pakete bleiben modul-lokal.
- Änderungen abwärtskompatibel halten, solange Module nicht gleichzeitig umgestellt werden.
- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul; die aktuelle Minor steht in `common/MINOR`, der Tag-Workflow setzt bei einer neuen Minor `v0.<minor>.0`.
- Sicherheitskritische Pakete (`svcauth`, `tenantregistryclient`, `auditclient`, `policyclient`, `apierror`, `httpharden`) unterliegen dem Pflichttest-Gate (`cmd/pflichttestgate`, Workflow `common-pflichttest-gate.yml`): Änderung ohne begleitende `_test.go` im selben Package schlägt fehl.
-71
View File
@@ -1,71 +0,0 @@
// Package bearerauth setzt die Identitaet der Modul-APIs aus einem
// verifizierten Core-Modul-Token. Die Handler lesen weiter X-User-Id und
// X-Tenant-Slug; die Middleware ueberschreibt beide aus den Claims, sodass ein
// Client sie nicht frei setzen kann. X-User-Role kommt nur aus dem Token.
package bearerauth
import (
"encoding/json"
"errors"
"net/http"
"strings"
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
)
const (
headerUser = "X-User-Id"
headerTenant = "X-Tenant-Slug"
headerRole = "X-User-Role"
)
// Middleware verlangt fuer Anfragen mit Authorization: Bearer ein gueltiges
// Token. Ohne Bearer werden X-User-Id und X-Tenant-Slug entfernt, ausser
// allowHeaderAuth ist gesetzt (Uebergang, bis alle Clients Bearer senden).
func Middleware(v *moduleauth.Verifier, allowHeaderAuth bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Del(headerRole)
token, ok := bearer(r)
if !ok {
if !allowHeaderAuth {
r.Header.Del(headerUser)
r.Header.Del(headerTenant)
}
next.ServeHTTP(w, r)
return
}
if v == nil {
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
return
}
claims, err := v.Verify(r.Context(), token)
if err != nil {
if errors.Is(err, moduleauth.ErrInvalidToken) {
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
} else {
writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar")
}
return
}
r.Header.Set(headerUser, claims.Subject)
r.Header.Set(headerTenant, claims.Tenant)
if claims.Role != "" {
r.Header.Set(headerRole, claims.Role)
}
next.ServeHTTP(w, r)
})
}
func bearer(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
return strings.TrimSpace(h[7:]), true
}
return "", false
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
-75
View File
@@ -1,75 +0,0 @@
package bearerauth
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
)
func echo() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(r.Header.Get("X-User-Id") + "|" + r.Header.Get("X-Tenant-Slug") + "|" + r.Header.Get("X-User-Role")))
})
}
func do(h http.Handler, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/x", nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestBearerOverridesClientSuppliedHeaders(t *testing.T) {
v, iss := moduleauth.NewTestVerifierAndIssuer()
h := Middleware(v, false, echo())
rec := do(h, map[string]string{
"Authorization": "Bearer " + iss.Token("u1", "acme", "user", "", ""),
"X-User-Id": "fremd", "X-Tenant-Slug": "fremd",
})
if rec.Code != 200 || rec.Body.String() != "u1|acme|user" {
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestInvalidBearerIs401(t *testing.T) {
v, _ := moduleauth.NewTestVerifierAndIssuer()
_, other := moduleauth.NewTestVerifierAndIssuer()
h := Middleware(v, true, echo())
if rec := do(h, map[string]string{"Authorization": "Bearer " + other.Token("u1", "acme", "user", "", ""), "X-User-Id": "evil"}); rec.Code != 401 {
t.Fatalf("code=%d", rec.Code)
}
if rec := do(Middleware(nil, true, echo()), map[string]string{"Authorization": "Bearer x.y.z"}); rec.Code != 401 {
t.Fatalf("ohne Verifier code=%d", rec.Code)
}
}
func TestWithoutBearerHeadersStrippedUnlessAllowed(t *testing.T) {
v, _ := moduleauth.NewTestVerifierAndIssuer()
hdr := map[string]string{"X-User-Id": "u9", "X-Tenant-Slug": "t9"}
if rec := do(Middleware(v, false, echo()), hdr); rec.Body.String() != "||" {
t.Fatalf("Header muessen entfernt werden, bekam %q", rec.Body.String())
}
if rec := do(Middleware(v, true, echo()), hdr); rec.Body.String() != "u9|t9|" {
t.Fatalf("Uebergangsmodus muss Header durchlassen, bekam %q", rec.Body.String())
}
}
func TestRoleOnlyFromToken(t *testing.T) {
v, iss := moduleauth.NewTestVerifierAndIssuer()
h := Middleware(v, true, echo())
rec := do(h, map[string]string{
"Authorization": "Bearer " + iss.Token("u1", "acme", "tenant_admin", "", ""),
"X-User-Role": "tenant_admin_fremd",
})
if rec.Body.String() != "u1|acme|tenant_admin" {
t.Fatalf("Rolle muss aus dem Token kommen, bekam %q", rec.Body.String())
}
if rec := do(h, map[string]string{"X-User-Role": "tenant_admin"}); rec.Body.String() != "||" {
t.Fatalf("Rolle ohne Token muss entfernt werden, bekam %q", rec.Body.String())
}
}
-50
View File
@@ -1,50 +0,0 @@
// Command pflichttestgate ist das CI-Gate für common/.
// Aufruf: pflichttestgate < geänderte-Dateien (eine pro Zeile, via stdin)
// Exit-Code 1 mit Liste der Verstöße, wenn ein sicherheitskritisches Package ohne
// begleitende Testdatei geändert wurde.
package main
import (
"bufio"
"fmt"
"os"
"regexp"
"gitea.perlbach24.de/scripte/nexarch/common/pflichttestgate"
)
var sensitive = []*regexp.Regexp{
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)common/tenantregistryclient/`),
regexp.MustCompile(`(^|/)common/auditclient/`),
regexp.MustCompile(`(^|/)common/policyclient/`),
regexp.MustCompile(`(^|/)common/apierror/`),
regexp.MustCompile(`(^|/)common/httpharden/`),
regexp.MustCompile(`(^|/)common/bearerauth/`),
}
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles, sensitive)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
os.Exit(1)
}
-12
View File
@@ -1,15 +1,3 @@
module gitea.perlbach24.de/scripte/nexarch/common
go 1.25.0
toolchain go1.25.13
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
-26
View File
@@ -1,26 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -5
View File
@@ -57,15 +57,16 @@ func TestClientDoesNotFollowRedirects(t *testing.T) {
}
}
// Architekturtest: im Produktivcode von common gibt es keine Aufrufe ueber den
// Standard-Client ohne Zeitlimit. Die Verbraucher-Module pruefen ihren eigenen
// Code selbst.
// Architekturtest: im Produktivcode der Module, die dieses Paket nutzen, gibt
// es keine Aufrufe ueber den Standard-Client ohne Zeitlimit.
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
root, err := filepath.Abs(filepath.Join(".."))
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
scanNoDefaultClient(t, root, root)
for _, mod := range []string{"common", "dms", "mail", "archive"} {
scanNoDefaultClient(t, repoRoot, filepath.Join(repoRoot, mod))
}
}
func scanNoDefaultClient(t *testing.T, repoRoot, root string) {
-247
View File
@@ -1,247 +0,0 @@
// Package migrate implements versioned, revertible SQL migrations (no ORM).
// Jedes Modul benennt seine Fortschrittstabelle selbst (New), damit sich
// Module in derselben Datenbank nicht gegenseitig den Fortschritt
// ueberschreiben (FDN-13).
package migrate
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// Migration ist eine einzelne versionierte Migrationsdatei.
type Migration struct {
Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents"
UpSQL string
DownSQL string
}
// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach
// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert).
func Load(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
continue
}
versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql"))
}
sort.Strings(versions)
migrations := make([]Migration, 0, len(versions))
for _, v := range versions {
up, err := os.ReadFile(filepath.Join(dir, v+".up.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err)
}
down, err := os.ReadFile(filepath.Join(dir, v+".down.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err)
}
migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)})
}
return migrations, nil
}
var tableNamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
// Migrator fuehrt Migrationen gegen eine modulspezifisch benannte Tabelle aus.
type Migrator struct {
table string
}
// New liefert einen Migrator fuer die Fortschrittstabelle table (z. B.
// "dms_schema_migrations"). Der Name geht unquotiert in SQL und muss daher
// ^[a-z_][a-z0-9_]{0,62}$ entsprechen.
func New(table string) (*Migrator, error) {
if !tableNamePattern.MatchString(table) {
return nil, fmt.Errorf("migrate: ungueltiger tabellenname %q", table)
}
return &Migrator{table: table}, nil
}
// MustNew wie New, panikt bei ungueltigem Namen (fuer Paketvariablen).
func MustNew(table string) *Migrator {
mg, err := New(table)
if err != nil {
panic(err)
}
return mg
}
// Table liefert den Namen der Fortschrittstabelle.
func (mg *Migrator) Table() string { return mg.table }
const legacyTrackingTable = "schema_migrations"
// ensureTrackingTable legt mg.table an. Beim erstmaligen Anlegen wird
// ein vorhandener Bestand aus der frueheren, unqualifizierten
// schema_migrations uebernommen (nur Versionen, die dieses Modul kennt, und
// nur wenn dessen Basisversion dort steht — eine schema_migrations eines
// anderen Moduls oder eines anderen Schemas wird weder gelesen noch
// veraendert). Die alte Tabelle
// bleibt unangetastet.
func (mg *Migrator) ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Serialisiert gleichzeitige Erstanlage/Uebernahme (mehrere App-Instanzen).
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, mg.table); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
var existed bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, mg.table).Scan(&existed); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
if existed {
return tx.Commit(ctx)
}
if _, err := tx.Exec(ctx, `
CREATE TABLE `+mg.table+` (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
if len(migrations) > 0 {
var legacy bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, legacyTrackingTable).Scan(&legacy); err != nil {
return fmt.Errorf("%s pruefen: %w", legacyTrackingTable, err)
}
if legacy {
versions := make([]string, len(migrations))
for i, m := range migrations {
versions[i] = m.Version
}
var hasBase bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM `+legacyTrackingTable+` WHERE version = $1)`, versions[0],
).Scan(&hasBase); err != nil {
return fmt.Errorf("%s lesen: %w", legacyTrackingTable, err)
}
if hasBase {
if _, err := tx.Exec(ctx, `
INSERT INTO `+mg.table+` (version, applied_at)
SELECT version, applied_at FROM `+legacyTrackingTable+` WHERE version = ANY($1)
`, versions); err != nil {
return fmt.Errorf("bestand aus %s uebernehmen: %w", legacyTrackingTable, err)
}
}
}
}
return tx.Commit(ctx)
}
func (mg *Migrator) appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
rows, err := pool.Query(ctx, `SELECT version FROM `+mg.table)
if err != nil {
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
}
defer rows.Close()
applied := map[string]bool{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("migrationsversion lesen: %w", err)
}
applied[v] = true
}
return applied, rows.Err()
}
// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
// DB funktioniert (Pruefung 1).
func (mg *Migrator) Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
return nil, err
}
already, err := mg.appliedVersions(ctx, pool)
if err != nil {
return nil, err
}
for _, m := range migrations {
if already[m.Version] {
continue
}
tx, err := pool.Begin(ctx)
if err != nil {
return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, m.UpSQL); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO `+mg.table+` (version) VALUES ($1)`, m.Version); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return applied, fmt.Errorf("migration %q committen: %w", m.Version, err)
}
applied = append(applied, m.Version)
}
return applied, nil
}
// DownOne macht die zuletzt angewendete Migration rueckgaengig
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
// oder "" falls keine Migration angewendet war.
func (mg *Migrator) DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
return "", err
}
already, err := mg.appliedVersions(ctx, pool)
if err != nil {
return "", err
}
var last *Migration
for i := len(migrations) - 1; i >= 0; i-- {
if already[migrations[i].Version] {
last = &migrations[i]
break
}
}
if last == nil {
return "", nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, last.DownSQL); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, `DELETE FROM `+mg.table+` WHERE version = $1`, last.Version); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, mg.table, err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
}
return last.Version, nil
}
-98
View File
@@ -1,98 +0,0 @@
package migrate
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
)
func TestNewValidatesTableName(t *testing.T) {
for _, ok := range []string{"dms_schema_migrations", "_x", "a1"} {
if _, err := New(ok); err != nil {
t.Errorf("New(%q): %v", ok, err)
}
}
for _, bad := range []string{"", "1abc", "Dms", "a-b", "a;drop table x", "a b", "schema.table"} {
if _, err := New(bad); err == nil {
t.Errorf("New(%q) sollte scheitern", bad)
}
}
}
func writeMigrations(t *testing.T, files map[string]string) []Migration {
t.Helper()
dir := t.TempDir()
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
migs, err := Load(dir)
if err != nil {
t.Fatal(err)
}
return migs
}
func TestUpDownAndSeparateTrackingTables(t *testing.T) {
ctx := context.Background()
pool := testdb.Pool(t, testdb.AdminDSN(t))
migs := writeMigrations(t, map[string]string{
"0001_a.up.sql": "CREATE TABLE mig_a (id int)",
"0001_a.down.sql": "DROP TABLE mig_a",
"0002_b.up.sql": "CREATE TABLE mig_b (id int)",
"0002_b.down.sql": "DROP TABLE mig_b",
})
one := MustNew("one_schema_migrations")
two := MustNew("two_schema_migrations")
applied, err := one.Up(ctx, pool, migs)
if err != nil || len(applied) != 2 {
t.Fatalf("Up: applied=%v err=%v", applied, err)
}
again, err := one.Up(ctx, pool, migs)
if err != nil || len(again) != 0 {
t.Fatalf("zweites Up soll nichts anwenden: applied=%v err=%v", again, err)
}
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM two_schema_migrations`).Scan(&n); err == nil {
t.Fatal("two_schema_migrations darf vor dem ersten Lauf von two nicht existieren")
}
if _, err := two.DownOne(ctx, pool, migs); err != nil {
t.Fatalf("two.DownOne: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM one_schema_migrations`).Scan(&n); err != nil || n != 2 {
t.Fatalf("Fortschritt von one muss unberuehrt sein: n=%d err=%v", n, err)
}
v, err := one.DownOne(ctx, pool, migs)
if err != nil || v != "0002_b" {
t.Fatalf("DownOne: v=%q err=%v", v, err)
}
}
func TestLegacyTrackingTableIsAdopted(t *testing.T) {
ctx := context.Background()
pool := testdb.Pool(t, testdb.AdminDSN(t))
migs := writeMigrations(t, map[string]string{
"0001_a.up.sql": "CREATE TABLE mig_leg (id int)",
"0001_a.down.sql": "DROP TABLE mig_leg",
})
for _, q := range []string{
`CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`INSERT INTO schema_migrations (version) VALUES ('0001_a')`,
`CREATE TABLE mig_leg (id int)`,
} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
applied, err := MustNew("leg_schema_migrations").Up(ctx, pool, migs)
if err != nil || len(applied) != 0 {
t.Fatalf("uebernommene Version darf nicht erneut laufen: applied=%v err=%v", applied, err)
}
}
-53
View File
@@ -1,53 +0,0 @@
// Package pflichttestgate erzwingt: jede geänderte Go-Datei in einem sicherheitskritischen
// Bereich muss von einer geänderten oder neuen _test.go-Datei im selben Package begleitet sein.
// Die Bereiche (Pfadmuster) liefert der Aufrufer, die Logik ist für alle Module dieselbe.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
type Violation struct {
Package string
ChangedFile string
}
func IsSensitive(file string, patterns []*regexp.Regexp) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range patterns {
if re.MatchString(file) {
return true
}
}
return false
}
func CheckDiff(changedFiles []string, patterns []*regexp.Regexp) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if IsSensitive(f, patterns) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
-54
View File
@@ -1,54 +0,0 @@
package pflichttestgate
import (
"regexp"
"testing"
)
var testPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)common/tenantregistryclient/`),
}
func TestCheckDiff_BlocksSensitiveChangeWithoutTest(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/loadtest/loadtest.go"}
violations := CheckDiff(changed, testPatterns)
if len(violations) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations)
}
if violations[0].Package != "common/svcauth" || violations[0].ChangedFile != "common/svcauth/svcauth.go" {
t.Errorf("unerwarteter Verstoß: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/svcauth/svcauth_test.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_TestInOtherPackageDoesNotCount(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/tenantregistryclient/tenantregistryclient_test.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_IgnoresNonGoAndUnrelated(t *testing.T) {
changed := []string{"common/svcauth/README.md", "common/httpclient/httpclient.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_NoPatternsMeansNoSensitiveAreas(t *testing.T) {
if v := CheckDiff([]string{"common/svcauth/svcauth.go"}, nil); len(v) != 0 {
t.Fatalf("ohne Muster darf nichts als sensibel gelten, bekommen %+v", v)
}
}
-11
View File
@@ -1,11 +0,0 @@
package testdb
import "testing"
func TestAdminDSNGesetzt(t *testing.T) {
t.Setenv("TEST_ADMIN_DSN", "postgres://x")
t.Setenv("REQUIRE_DB", "1")
if got := AdminDSN(t); got != "postgres://x" {
t.Fatalf("dsn = %q", got)
}
}
+3 -86
View File
@@ -15,15 +15,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/bearerauth"
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
"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/datevapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2import"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2importapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
@@ -41,43 +35,6 @@ func (f fixedKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, err
return f.kek, nil
}
// buildPolicyClient liefert nil nur mit NEXARCH_CONNECT_APP_POLICY_DISABLED=1;
// ohne URL und ohne diesen Schalter startet der Dienst nicht.
func buildPolicyClient() *policyclient.Client {
policyURL := os.Getenv("NEXARCH_CONNECT_APP_POLICY_URL")
if policyURL == "" {
if os.Getenv("NEXARCH_CONNECT_APP_POLICY_DISABLED") != "1" {
log.Fatal("connect-app: NEXARCH_CONNECT_APP_POLICY_URL fehlt (oder NEXARCH_CONNECT_APP_POLICY_DISABLED=1 als Uebergang setzen)")
}
log.Print("connect-app: WARNUNG: Rechtepruefung connect.status.read/connect.setup.* deaktiviert (NEXARCH_CONNECT_APP_POLICY_DISABLED=1)")
return nil
}
token := os.Getenv("NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN")
if token == "" {
log.Fatal("connect-app: NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_CONNECT_APP_POLICY_URL gesetzt ist")
}
return policyclient.New(policyURL, token)
}
// buildDatevExports liefert nil ohne NEXARCH_CONNECT_APP_ARCHIVE_URL; dann ist
// /datev/exports nicht registriert. Die DATEV-Uebertragung ist hier nicht verdrahtet.
func buildDatevExports(pool *pgxpool.Pool) *datevexport.Store {
archiveURL := os.Getenv("NEXARCH_CONNECT_APP_ARCHIVE_URL")
if archiveURL == "" {
log.Print("connect-app: NEXARCH_CONNECT_APP_ARCHIVE_URL fehlt, /datev/exports nicht registriert")
return nil
}
token := os.Getenv("NEXARCH_CONNECT_APP_ARCHIVE_SERVICE_TOKEN")
if token == "" {
log.Fatal("connect-app: NEXARCH_CONNECT_APP_ARCHIVE_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_CONNECT_APP_ARCHIVE_URL gesetzt ist")
}
return &datevexport.Store{
Pool: pool,
Archive: archiveclient.New(archiveURL, token),
CallbackURL: os.Getenv("NEXARCH_CONNECT_APP_DATEV_CALLBACK_URL"),
}
}
func main() {
if !killswitch.Enabled() {
log.Printf("connect-app: deaktiviert (%s=false), kein Start", killswitch.EnvEnabled)
@@ -108,25 +65,18 @@ func main() {
}
defer pool.Close()
verifier, allowHeaderAuth := buildIdentity()
mappings := fieldmapping.NewStore(pool)
credentials := credential.NewStore(pool, crypto.NewService(fixedKEK{kek: kek}))
policy := buildPolicyClient()
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
statusapi.NewServer(syncjob.NewStore(pool)).WithPolicy(policy).RegisterRoutes(mux)
statusapi.NewServer(syncjob.NewStore(pool)).RegisterRoutes(mux)
dms2importapi.NewServer(mappings, dms2import.NewStore(pool)).RegisterRoutes(mux)
connectorsetupapi.NewServer(credentials, mappings).WithPolicy(policy).RegisterRoutes(mux)
if exports := buildDatevExports(pool); exports != nil {
datevapi.NewServer(exports, nil).WithPolicy(policy).RegisterRoutes(mux)
}
connectorsetupapi.NewServer(credentials, mappings).RegisterRoutes(mux)
srv := &http.Server{
Addr: addr,
Handler: bearerauth.Middleware(verifier, allowHeaderAuth, mux),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
@@ -148,36 +98,3 @@ func main() {
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
// buildIdentity richtet die Token-Pruefung ein. Ohne NEXARCH_CONNECT_APP_JWKS_URL
// muss der Uebergangsschalter NEXARCH_CONNECT_APP_ALLOW_HEADER_AUTH=1 gesetzt
// sein; sonst startet der Dienst nicht. Die Datenbank gehoert einem Mandanten,
// daher ist NEXARCH_CONNECT_APP_TENANT_SLUG mit JWKS Pflicht.
func buildIdentity() (*moduleauth.Verifier, bool) {
allowHeader := os.Getenv("NEXARCH_CONNECT_APP_ALLOW_HEADER_AUTH") == "1"
jwksURL := os.Getenv("NEXARCH_CONNECT_APP_JWKS_URL")
if allowHeader {
log.Print("connect-app: WARNUNG: X-User-Id/X-Tenant-Slug ohne Token zugelassen (NEXARCH_CONNECT_APP_ALLOW_HEADER_AUTH=1), nicht authentifiziert")
}
if jwksURL == "" {
if !allowHeader {
log.Fatal("connect-app: NEXARCH_CONNECT_APP_JWKS_URL fehlt (oder NEXARCH_CONNECT_APP_ALLOW_HEADER_AUTH=1 als Uebergang setzen)")
}
return nil, allowHeader
}
tenantSlug := os.Getenv("NEXARCH_CONNECT_APP_TENANT_SLUG")
if tenantSlug == "" {
log.Fatal("connect-app: NEXARCH_CONNECT_APP_TENANT_SLUG ist mit NEXARCH_CONNECT_APP_JWKS_URL erforderlich")
}
audience := os.Getenv("NEXARCH_CONNECT_APP_JWT_AUDIENCE")
if audience == "" {
audience = "connect"
}
issuer := os.Getenv("NEXARCH_CONNECT_APP_JWT_ISSUER")
if issuer == "" {
issuer = "nexarch-core"
}
enforce := os.Getenv("NEXARCH_CONNECT_APP_JWT_AUDIENCE_ENFORCE") != "false"
v := moduleauth.NewVerifier(5*time.Minute, tenantSlug, moduleauth.HTTPKeyFetcher(jwksURL, nil)).Expect(audience, issuer, enforce)
return v, allowHeader
}
+6 -5
View File
@@ -4,15 +4,16 @@ go 1.25.0
toolchain go1.25.13
require (
gitea.perlbach24.de/scripte/nexarch/common v0.2.2
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/text v0.29.0
)
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
-2
View File
@@ -1,5 +1,3 @@
gitea.perlbach24.de/scripte/nexarch/common v0.2.2 h1:PupmTMm3xysOP8J+zgqRAgwro1XlaQn0njsuUgbqN14=
gitea.perlbach24.de/scripte/nexarch/common v0.2.2/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,11 +15,11 @@ import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/syncjob"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
type fakeKEK struct{ key []byte }
@@ -2,8 +2,7 @@
// 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. Issue #369: every route also
// checks connect.setup.read/write against Core (requirePermission).
// connect/internal/statusapi already uses.
package connectorsetupapi
import (
@@ -12,58 +11,26 @@ import (
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorcatalog"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
)
const (
permissionSetupRead = "connect.setup.read"
permissionSetupWrite = "connect.setup.write"
)
type Server struct {
credentials *credential.Store
mappings *fieldmapping.Store
policy *policyclient.Client
}
func NewServer(credentials *credential.Store, mappings *fieldmapping.Store) *Server {
return &Server{credentials: credentials, mappings: mappings}
}
// WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize).
func (s *Server) WithPolicy(client *policyclient.Client) *Server {
s.policy = client
return s
}
type handler func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string)
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /connectors/catalog", s.requireIdentity(s.requirePermission(permissionSetupRead, s.handleCatalog)))
mux.HandleFunc("GET /connectors/connections", s.requireIdentity(s.requirePermission(permissionSetupRead, s.handleListConnections)))
mux.HandleFunc("PUT /connectors/connections/{type}/{instance}", s.requireIdentity(s.requirePermission(permissionSetupWrite, s.handlePutConnection)))
mux.HandleFunc("GET /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.requirePermission(permissionSetupRead, s.handleGetMapping)))
mux.HandleFunc("PUT /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.requirePermission(permissionSetupWrite, s.handlePutMapping)))
}
func (s *Server) requirePermission(permission string, next handler) handler {
return func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string) {
if s.policy != nil {
allowed, err := s.policy.Allowed(r.Context(), tenantSlug, roleOf(r), permission)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
return
}
if !allowed {
writeError(w, http.StatusForbidden, "keine berechtigung")
return
}
}
next(w, r, tenantSlug, userID)
}
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 {
@@ -251,10 +218,3 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func roleOf(r *http.Request) string {
if role := r.Header.Get("X-User-Role"); role != "" {
return role
}
return "user"
}
@@ -1,139 +0,0 @@
package connectorsetupapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
)
// Steht fuer Core's POST /authorize-Vertrag ({"allowed": bool}); prueft die
// echte Kette requireIdentity -> requirePermission -> policyclient ->
// 403/next. Ohne Datenbank: der Katalog braucht keinen Store, und die
// Ablehnung muss vor jedem Store-Zugriff greifen (Stores bewusst nil).
func newFakePolicyAPI(t *testing.T, allow bool, seen *[]map[string]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/authorize" || r.Header.Get("X-Service-Token") == "" {
t.Fatalf("unerwartete anfrage: %s %s", r.Method, r.URL.Path)
}
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if seen != nil {
*seen = append(*seen, body)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow})
}))
}
func policyMux(policyURL string) *http.ServeMux {
mux := http.NewServeMux()
NewServer(nil, nil).WithPolicy(policyclient.New(policyURL, "test-token")).RegisterRoutes(mux)
return mux
}
func TestCatalog_PolicyDenies_Rejected(t *testing.T) {
var seen []map[string]string
policyAPI := newFakePolicyAPI(t, false, &seen)
defer policyAPI.Close()
mux := policyMux(policyAPI.URL)
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("GET", "/connectors/catalog", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", w.Code, w.Body.String())
}
if len(seen) != 1 || seen[0]["permission"] != "connect.setup.read" || seen[0]["tenant"] != "acme" {
t.Fatalf("falsche policy-anfrage: %+v", seen)
}
}
func TestWriteRoutes_PolicyDenies_RejectedBeforeStore(t *testing.T) {
var seen []map[string]string
policyAPI := newFakePolicyAPI(t, false, &seen)
defer policyAPI.Close()
mux := policyMux(policyAPI.URL)
for _, url := range []string{
"/connectors/connections/erp/acme-erp-1",
"/connectors/connections/erp/acme-erp-1/mapping",
} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("PUT", url, map[string]any{}))
if w.Code != http.StatusForbidden {
t.Fatalf("PUT %s: erwartet 403, bekam %d: %s", url, w.Code, w.Body.String())
}
}
for _, url := range []string{"/connectors/connections", "/connectors/connections/erp/acme-erp-1/mapping"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("GET", url, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("GET %s: erwartet 403, bekam %d: %s", url, w.Code, w.Body.String())
}
}
if len(seen) != 4 || seen[0]["permission"] != "connect.setup.write" || seen[1]["permission"] != "connect.setup.write" ||
seen[2]["permission"] != "connect.setup.read" || seen[3]["permission"] != "connect.setup.read" {
t.Fatalf("falsche policy-anfragen: %+v", seen)
}
}
func TestCatalog_PolicyAllows_Passes(t *testing.T) {
policyAPI := newFakePolicyAPI(t, true, nil)
defer policyAPI.Close()
mux := policyMux(policyAPI.URL)
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("GET", "/connectors/catalog", nil))
if w.Code != http.StatusOK {
t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", w.Code, w.Body.String())
}
}
func TestCatalog_PolicyUnavailable_FailsClosed(t *testing.T) {
policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer policyAPI.Close()
mux := policyMux(policyAPI.URL)
w := httptest.NewRecorder()
mux.ServeHTTP(w, authedRequest("GET", "/connectors/catalog", nil))
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("erwartet 503 bei ausfall des berechtigungsdienstes, bekam %d", w.Code)
}
}
func TestCatalog_PolicyNotAskedWithoutIdentity(t *testing.T) {
var seen []map[string]string
policyAPI := newFakePolicyAPI(t, true, &seen)
defer policyAPI.Close()
mux := policyMux(policyAPI.URL)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/connectors/catalog", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401 ohne identitaet, bekam %d", w.Code)
}
if len(seen) != 0 {
t.Fatalf("policy darf ohne identitaet nicht befragt werden: %+v", seen)
}
}
func TestPolicyAsked_WithRoleFromHeader_DefaultUser(t *testing.T) {
var seen []map[string]string
policyAPI := newFakePolicyAPI(t, false, &seen)
defer policyAPI.Close()
mux := policyMux(policyAPI.URL)
mux.ServeHTTP(httptest.NewRecorder(), authedRequest("GET", "/connectors/catalog", nil))
admin := authedRequest("GET", "/connectors/catalog", nil)
admin.Header.Set("X-User-Role", "tenant_admin")
mux.ServeHTTP(httptest.NewRecorder(), admin)
if len(seen) != 2 || seen[0]["role"] != "user" || seen[1]["role"] != "tenant_admin" {
t.Fatalf("falsche rollen in policy-anfragen: %+v", seen)
}
}
@@ -14,10 +14,10 @@ import (
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"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 }
@@ -13,9 +13,9 @@ import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/webhookclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/webhooksub"
)
+1 -1
View File
@@ -13,10 +13,10 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
// coreKEKStub stands in for Core API-10's TenantKEKHandler, always serving
@@ -14,11 +14,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/audittrail"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
type fakeKEK struct{ kek []byte }
@@ -11,10 +11,10 @@ import (
"sync/atomic"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
// QA-04 Pruefung 3: ein Credential, das VOR einer Core-Tenant-KEK-Rotation
+10 -41
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
)
@@ -17,12 +16,6 @@ import (
type Server struct {
Exports *datevexport.Store
Transfers *datevtransfer.Store
policy *policyclient.Client
}
func (s *Server) WithPolicy(client *policyclient.Client) *Server {
s.policy = client
return s
}
func NewServer(exports *datevexport.Store, transfers *datevtransfer.Store) *Server {
@@ -30,32 +23,8 @@ func NewServer(exports *datevexport.Store, transfers *datevtransfer.Store) *Serv
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /datev/exports", s.requireIdentity(s.requirePermission("connect.datev.write", s.handleTrigger)))
mux.HandleFunc("GET /datev/exports", s.requireIdentity(s.requirePermission("connect.datev.read", s.handleList)))
}
func (s *Server) requirePermission(permission string, next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) func(w http.ResponseWriter, r *http.Request, tenantSlug string) {
return func(w http.ResponseWriter, r *http.Request, tenantSlug string) {
if s.policy != nil {
allowed, err := s.policy.Allowed(r.Context(), tenantSlug, roleOf(r), permission)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
return
}
if !allowed {
writeError(w, http.StatusForbidden, "keine berechtigung")
return
}
}
next(w, r, tenantSlug)
}
}
func roleOf(r *http.Request) string {
if role := r.Header.Get("X-User-Role"); role != "" {
return role
}
return "user"
mux.HandleFunc("POST /datev/exports", s.requireIdentity(s.handleTrigger))
mux.HandleFunc("GET /datev/exports", s.requireIdentity(s.handleList))
}
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc {
@@ -85,14 +54,14 @@ type triggerRequest struct {
}
type runResponse struct {
ID string `json:"id"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
EntryCount int `json:"entry_count"`
RetentionClass string `json:"retention_class"`
CreatedAt string `json:"created_at"`
TransferStatus string `json:"transfer_status,omitempty"`
TransferError string `json:"transfer_error,omitempty"`
ID string `json:"id"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
EntryCount int `json:"entry_count"`
RetentionClass string `json:"retention_class"`
CreatedAt string `json:"created_at"`
TransferStatus string `json:"transfer_status,omitempty"`
TransferError string `json:"transfer_error,omitempty"`
}
func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request, tenantSlug string) {
@@ -1,46 +0,0 @@
package datevapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
)
func TestPolicy_PermissionAndRoleReachCore(t *testing.T) {
type ask struct{ role, permission string }
var got []ask
policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Role string `json:"role"`
Permission string `json:"permission"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
got = append(got, ask{body.Role, body.Permission})
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": false})
}))
defer policyAPI.Close()
mux := http.NewServeMux()
(&Server{}).WithPolicy(policyclient.New(policyAPI.URL, "tok")).RegisterRoutes(mux)
for _, c := range []struct{ method, role string }{{"GET", "tenant_admin"}, {"POST", ""}} {
req := httptest.NewRequest(c.method, "/datev/exports", strings.NewReader("{}"))
req.Header.Set("X-User-Id", "u1")
req.Header.Set("X-Tenant-Slug", "acme")
if c.role != "" {
req.Header.Set("X-User-Role", c.role)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("%s: erwartet 403, bekam %d", c.method, rec.Code)
}
}
want := []ask{{"tenant_admin", "connect.datev.read"}, {"user", "connect.datev.write"}}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("erwartet %v, bekam %v", want, got)
}
}
+1 -1
View File
@@ -12,11 +12,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
func fakeArchiveRegister(t *testing.T) *httptest.Server {
+7 -7
View File
@@ -34,13 +34,13 @@ var (
// (a deliberate subset, not the full ~116-column spec — DAT-02/DAT-03
// extend format coverage, not this ticket).
type Entry struct {
Amount float64 // Umsatz (ohne Soll/Haben-Kennzeichen), immer positiv
DebitCredit string // "S" (Soll) oder "H" (Haben)
Account string // Konto
CounterAccount string // Gegenkonto (ohne BU-Schlüssel)
Date time.Time // Belegdatum
Text string // Buchungstext
DocumentRef string // Belegfeld 1 (Rechnungs-/Belegnummer)
Amount float64 // Umsatz (ohne Soll/Haben-Kennzeichen), immer positiv
DebitCredit string // "S" (Soll) oder "H" (Haben)
Account string // Konto
CounterAccount string // Gegenkonto (ohne BU-Schlüssel)
Date time.Time // Belegdatum
Text string // Buchungstext
DocumentRef string // Belegfeld 1 (Rechnungs-/Belegnummer)
}
func (e Entry) validate() error {
@@ -89,7 +89,7 @@ func TestExport_StructuralFormatCheck(t *testing.T) {
// AC2: fehlendes Pflichtfeld im Beleg führt zur klaren Abweisung.
func TestExport_MissingMandatoryField_Rejected(t *testing.T) {
cases := []struct {
name string
name string
break_ func(e Entry) Entry
}{
{"Umsatz", func(e Entry) Entry { e.Amount = 0; return e }},
+2 -2
View File
@@ -30,8 +30,8 @@ type Run struct {
}
type Store struct {
Pool *pgxpool.Pool
Archive *archiveclient.Client
Pool *pgxpool.Pool
Archive *archiveclient.Client
// CallbackURL is where Archive would call back on deletion — Connect
// has no export-deletion path yet (DAT-02/03), so this points at a
// stub endpoint that always 501s, same pattern as other modules'
+2 -1
View File
@@ -10,9 +10,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
// fakeArchiveRegister replicates archive/internal/moduleadapter's real
@@ -161,3 +161,4 @@ func countRuns(t *testing.T, pool *pgxpool.Pool, tenantSlug string) int {
}
return n
}
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
func openTestPool(t *testing.T) *pgxpool.Pool {
Loaded 100 of 435 files, more files were not shown because too many files have changed in this diff. Show more