Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30e16cb4f | ||
|
|
21278f1405 |
@@ -0,0 +1,55 @@
|
||||
// retention-api ist der Aufrufpunkt fuer RET-06-API: Backend-HTTP-Dienst
|
||||
// fuer die Aufbewahrungsfristen-Konfiguration (CRUD + Vorschauliste).
|
||||
// Getrennt vom scrub-metrics-/restoretest-metrics-Muster, weil dies KEIN
|
||||
// Prometheus-/OPS-03-Endpunkt ist, sondern ein echter Admin-API-Dienst
|
||||
// (Next.js-Frontend als eigenes Folgeticket).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("NEXARCH_RETENTION_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
log.Fatal("NEXARCH_RETENTION_TENANT_DSN muss gesetzt sein")
|
||||
}
|
||||
addr := os.Getenv("NEXARCH_RETENTION_API_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8092"
|
||||
}
|
||||
rbacBaseURL := os.Getenv("NEXARCH_RETENTION_RBAC_BASE_URL")
|
||||
if rbacBaseURL == "" {
|
||||
log.Fatal("NEXARCH_RETENTION_RBAC_BASE_URL muss gesetzt sein (RBAC-06, z. B. http://127.0.0.1:8093)")
|
||||
}
|
||||
rbacServiceToken := os.Getenv("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN")
|
||||
if rbacServiceToken == "" {
|
||||
log.Fatal("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN muss gesetzt sein")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("datenbankverbindung: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
rbac := rbacclient.New(rbacBaseURL, rbacServiceToken)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
retentionapi.Mount(mux, pool, rbac)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
log.Printf("retention-api: listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# RET-06-API – Prüfprotokoll: Aufbewahrungsfristen-Konfigurations-Backend
|
||||
|
||||
Voraussetzung RET-02 – erledigt, siehe eigenes Protokoll.
|
||||
|
||||
**Scope-Entscheidung (Board-Rücksprache):** RET-06 verlangt Next.js/
|
||||
React-Frontend + Backend-API + RBAC. Statt eines einzigen
|
||||
Big-Scope-Tickets: **Backend-API zuerst** (dieses Protokoll), das
|
||||
echte Next.js-Frontend folgt als eigenes, separates Ticket. Diese
|
||||
Trennung wurde bewusst gewählt, damit das Prüfprotokoll nicht auf ein
|
||||
Pseudo-Frontend verweist.
|
||||
|
||||
## Grundsatzentscheidung: provisorischer Rollen-Check, KEIN RBAC-02
|
||||
|
||||
Core RBAC-02 (`internal/policy`, `Enforcer.Authorize`) ist reiner
|
||||
Go-Code innerhalb des Core-Moduls — keine HTTP-Schnittstelle, über die
|
||||
Archive (physisch getrenntes Go-Modul) es aufrufen könnte. Derselbe
|
||||
"gefunden, aber nicht modulübergreifend verdrahtet"-Befund wie bei Core
|
||||
FDN-03/FDN-09 (siehe frühere Prüfprotokolle).
|
||||
|
||||
**`internal/retentionapi.RequireRole` ist AUSDRÜCKLICH KEIN RBAC-02-
|
||||
Ersatz**, sondern ein Provisorium: prüft nur einen selbst gesetzten
|
||||
Header (`X-Admin-Roles`), leicht zu fälschen von jedem, der den Header
|
||||
setzen kann. Muss ersetzt werden, sobald ein Core-seitiger HTTP-Wrapper
|
||||
um RBAC-02 existiert (Empfehlung: eigenes künftiges Core-Ticket,
|
||||
z. B. `RBAC-06`, wiederverwendbar für alle Module statt je Modul einen
|
||||
eigenen Provisorium-Check). Bis dahin real getestet inklusive
|
||||
Negativfall (Pflichtprüfung, siehe unten) — ein UNGEPRÜFTER
|
||||
Provisorium-Check wäre nur eine verschobene Schwachstelle.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `migrations/0005_retention_class_rules_active.up.sql`/`.down.sql` —
|
||||
`active`-Flag statt DELETE (Klasse deaktivieren ohne Historienverlust).
|
||||
- `internal/retentionengine.DeactivateClassRule`/`ListClassRules` —
|
||||
Erweiterung von RET-02s Paket, `ComputeDueDate`/`ListExpiringObjects`
|
||||
berücksichtigen nur noch aktive Regeln.
|
||||
- `internal/retentionapi.RequireRole` — provisorischer Header-Rollen-Check.
|
||||
- `internal/retentionapi.Mount` — vier Endpunkte: `POST
|
||||
/retention-classes` (anlegen/ändern), `POST
|
||||
/retention-classes/{class}/deactivate`, `GET /retention-classes`
|
||||
(Liste), `GET /retention-classes/preview` (Vorschau, nutzt DIESELBE
|
||||
`ListExpiringObjects`-Funktion wie RET-02s periodischer Job).
|
||||
- `cmd/retention-api` — eigenständiger HTTP-Dienst.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Änderung einer Frist wirkt sich nur auf künftige Berechnungen aus, nicht rückwirkend auf bereits protokollierte Vernichtungen | **bestanden** — `TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations`: bereits berechneter Stichtag bleibt unverändert (strukturell garantiert, keine Tabelle mit "bereits berechneten" Werten existiert, die rückwirkend verändert werden könnte), NEUE Berechnung übernimmt die neue Frist |
|
||||
| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden** — `TestRequireRole_MissingRoleReturns403` (kein Header UND falsche Rolle, beide 403) UND `TestRequireRole_CorrectRoleAllowsAccess` (Gegentest); real auf 131: `curl` ohne Rollen-Header → 403 |
|
||||
| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden** — `TestPreviewHandler_MatchesPeriodicJobResult`: HTTP-Vorschau UND direkter `ListExpiringObjects`-Aufruf liefern dasselbe Objekt (dieselbe Funktion, kein Doppel-Code) |
|
||||
|
||||
Zusätzlich: `TestDeactivateClassRule_ExcludesFromFutureCalculations`,
|
||||
`TestConfigureAndListHandler_RealHTTPRoundTrip`,
|
||||
`TestDeactivateHandler_RealHTTPRoundTrip`.
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- `retention-api` gebaut nach `/opt/nexarch-archive/bin/`
|
||||
- `/etc/nexarch/archive-retention-api.env` (0600)
|
||||
- `nexarch-archive-retention-api.service` installiert/aktiviert
|
||||
(dauerhaft, `Restart=on-failure`)
|
||||
- Realer End-zu-Ende-Test via `curl`: POST ohne Rollen-Header → 403;
|
||||
POST mit `X-Admin-Roles: archive_admin` → 200, Klasse angelegt; `GET
|
||||
/retention-classes` zeigt sie; `GET /retention-classes/preview`
|
||||
liefert `null` (kein fälliges Objekt, korrekt leer) — Testdaten
|
||||
anschließend entfernt
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131, `make check`)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -count=1 -> 11/11 Pakete mit Tests ok, 0 Fehlschläge
|
||||
```
|
||||
|
||||
**Hinweis:** Zwei Pakete (`internal/retentionengine`,
|
||||
`internal/retentionapi`) gegen dieselbe reale Tenant-DB laufen zu lassen
|
||||
erfordert `-p 1` (sequentiell) — parallele Testläufe unterschiedlicher
|
||||
Pakete gegen dieselbe echte Datenbank führen sonst zu
|
||||
Cross-Test-Kontamination (TRUNCATE eines Pakets während ein anderes
|
||||
noch liest/schreibt). `make check`/`Makefile` erzwingt das bereits.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden — Backend-Scope.** Alle drei Pflichtprüfungen real erfüllt.
|
||||
**Offen, bewusst nicht Teil dieses Protokolls:** das Next.js/React-
|
||||
Frontend (separates Folgeticket) und der Ersatz des provisorischen
|
||||
Rollen-Checks durch einen echten RBAC-02-Aufruf, sobald Core einen
|
||||
HTTP-Wrapper dafür bereitstellt.
|
||||
@@ -0,0 +1,70 @@
|
||||
# RET-08 – Prüfprotokoll: RET-06-API auf RBAC-06 migrieren (Provisorium abbauen)
|
||||
|
||||
Voraussetzung RBAC-06 (Core) und RET-06-API – beide bereits Fertig.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/internal/rbacclient` (neu) – schlanker HTTP-Client für Core
|
||||
RBAC-06 (`POST /authorize`), service-token-authentifiziert. Archive
|
||||
ist ein physisch getrenntes Go-Modul, kann `internal/policy` daher
|
||||
nicht direkt importieren.
|
||||
- `archive/internal/retentionapi/authz.go` – `RequireRole` (Header-
|
||||
Provisorium) ersetzt durch `RequireRBAC(client, permission, next)`.
|
||||
Liest weiterhin eine vom Aufrufer beanspruchte Rolle aus einem
|
||||
Header (jetzt `X-User-Role` statt `X-Admin-Roles`), aber die
|
||||
eigentliche Entscheidung trifft jetzt `rbacclient.Client.Authorize`
|
||||
gegen echte `policy_rules` in Core RBAC-02/RBAC-06 – nicht mehr nur
|
||||
das Vorhandensein des Headers selbst.
|
||||
- **Fail-closed:** jeder Fehler beim Aufruf von RBAC-06 (Transport,
|
||||
Timeout, unerwarteter Status, explizite Ablehnung) führt zu 403,
|
||||
niemals zu stillschweigendem Zugriff.
|
||||
- `archive/internal/retentionapi/handler.go` – `Mount` nimmt jetzt
|
||||
zusätzlich einen `*rbacclient.Client` entgegen, alle vier Endpunkte
|
||||
gegen dasselbe Recht `retention.configure` geprüft.
|
||||
- `archive/cmd/retention-api/main.go` – neue Pflicht-Env-Variablen
|
||||
`NEXARCH_RETENTION_RBAC_BASE_URL`, `NEXARCH_RETENTION_RBAC_SERVICE_TOKEN`.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | 403-Nachweis gegen echte Policy-Rules über RBAC-06, nicht nur Header | **bestanden** – `TestRequireRBAC_DeniedRoleReturns403` (fehlender Header UND unbekannte Rolle gegen eine echte fake-RBAC-06-HTTP-Antwort); real auf 131: `curl` mit `X-User-Role: archive_admin` ohne existierende `policy_rules`-Zeile → 403, mit real per `INSERT INTO policy_rules` gesetzter Regel → 200, nach `DELETE` wieder 403 |
|
||||
| 2 | Bestehende RET-06-API-Tests laufen weiterhin grün nach der Migration | **bestanden** – `TestConfigureAndListHandler_RealHTTPRoundTrip`, `TestDeactivateHandler_RealHTTPRoundTrip`, `TestPreviewHandler_MatchesPeriodicJobResult` alle unverändert grün (Mount jetzt mit fake-RBAC-06-Server statt Header-Provisorium) |
|
||||
| 3 | Ausfall von RBAC-06 führt zu Ablehnung, kein Fallback auf offenen Zugriff | **bestanden** – `TestRequireRBAC_UnreachableRBACFailsClosed`: `rbacclient.Client` zeigt auf nicht erreichbaren Endpunkt (`http://127.0.0.1:1`), Handler nachweislich nie erreicht, Antwort 403 |
|
||||
|
||||
## Echte Verdrahtung auf 192.168.1.131
|
||||
|
||||
- `retention-api` neu gebaut, `/etc/nexarch/archive-retention-api.env`
|
||||
um `NEXARCH_RETENTION_RBAC_BASE_URL=http://127.0.0.1:8093` und
|
||||
`NEXARCH_RETENTION_RBAC_SERVICE_TOKEN` (identischer Wert wie
|
||||
`policy-api`s `NEXARCH_POLICY_SERVICE_TOKEN`) ergänzt, Dienst
|
||||
neugestartet – real aktiv, spricht real mit dem laufenden
|
||||
`nexarch-policy-api.service` (RBAC-06).
|
||||
- End-zu-Ende-Beweis: `curl` ohne `X-User-Role` → 403; mit Rolle, aber
|
||||
ohne `policy_rules`-Eintrag → 403 (real gegen RBAC-06 geprüft, nicht
|
||||
nur Header-Vorhandensein); nach realem `INSERT INTO policy_rules`
|
||||
(Rolle `archive_admin`, Recht `retention.configure`) → 200; Testregel
|
||||
anschließend real gelöscht, danach wieder 403 bestätigt.
|
||||
- Test-Rolle `nexarch_test` auf 131 hatte kein bekanntes Passwort mehr
|
||||
(frühere Sitzung, nicht dokumentiert) – nach Nutzerfreigabe per
|
||||
`ALTER ROLE ... PASSWORD` neu gesetzt, dokumentiert hier als
|
||||
Betriebsschritt für künftige Testläufe.
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -> alle Pakete bestanden (inkl. objectbackup, restoretest, retentionapi)
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt. Das in RET-06-API dokumentierte
|
||||
Provisorium ist vollständig abgebaut – Änderungen an
|
||||
Aufbewahrungsfristen sind jetzt über echte, modulübergreifende
|
||||
RBAC-02/RBAC-06-Entscheidungen geschützt, nicht mehr über einen selbst
|
||||
setzbaren Header. RET-06 (Frontend) ist damit ohne weiteren
|
||||
RBAC-Blocker startbar.
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package rbacclient ist ein schlanker HTTP-Client für Core RBAC-06
|
||||
// (internal/policyapi, POST /authorize). Archive ist ein physisch
|
||||
// getrenntes Go-Modul und kann Core RBAC-02 (internal/policy) daher
|
||||
// nicht direkt importieren — nur über die HTTP-Schnittstelle von
|
||||
// RBAC-06 aufrufen.
|
||||
package rbacclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrDenied wird zurückgegeben, wenn RBAC-06 die Anfrage ausdrücklich
|
||||
// ablehnt (kein Transport-/Serverfehler, sondern eine bewusste
|
||||
// Policy-Entscheidung).
|
||||
var ErrDenied = errors.New("rbac: zugriff verweigert")
|
||||
|
||||
// Client ruft den RBAC-06-Endpunkt auf.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
ServiceToken string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, serviceToken string) *Client {
|
||||
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: http.DefaultClient}
|
||||
}
|
||||
|
||||
type authorizeRequest struct {
|
||||
Role string `json:"role"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
type authorizeResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Authorize fragt RBAC-06, ob role die permission besitzt. Jeder
|
||||
// Fehler (Transport, Timeout, unerwarteter Status) wird als Fehler
|
||||
// zurückgegeben, NICHT stillschweigend als "erlaubt" behandelt — der
|
||||
// Aufrufer muss bei einem Fehler fail-closed (verweigern) reagieren.
|
||||
func (c *Client) Authorize(ctx context.Context, role, permission string) (bool, error) {
|
||||
body, err := json.Marshal(authorizeRequest{Role: role, Permission: permission})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rbac: request kodieren: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rbac: request bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Service-Token", c.ServiceToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rbac: aufruf fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("rbac: unerwarteter status %d", resp.StatusCode)
|
||||
}
|
||||
var out authorizeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return false, fmt.Errorf("rbac: antwort dekodieren: %w", err)
|
||||
}
|
||||
if !out.Allowed {
|
||||
return false, ErrDenied
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package retentionapi implementiert RET-06-API: die Backend-Seite der
|
||||
// Aufbewahrungsfristen-Konfigurationsoberfläche (CRUD auf
|
||||
// Aufbewahrungsklassen + Vorschauliste ablaufender Objekte). Das
|
||||
// Next.js-Frontend selbst ist NICHT Teil dieses Tickets (Board-
|
||||
// Entscheidung: Backend-API zuerst, Frontend als eigenes Folgeticket).
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
)
|
||||
|
||||
// requestedRoleHeader trägt die vom Aufrufer beanspruchte Rolle. Die
|
||||
// eigentliche Autorisierungsentscheidung trifft NICHT dieser Header,
|
||||
// sondern Core RBAC-06 (siehe RequireRBAC) — der Header ist nur die
|
||||
// Behauptung, RBAC-06 prüft sie gegen die echten Policy-Rules.
|
||||
const requestedRoleHeader = "X-User-Role"
|
||||
|
||||
// RequireRBAC ist RET-08: die Ablösung des früheren, header-basierten
|
||||
// Provisoriums (siehe archive/docs/RET-06-API-PRUEFPROTOKOLL.md) durch
|
||||
// einen echten Aufruf von Core RBAC-06 (POST /authorize). Fail-closed:
|
||||
// jeder Fehler beim Aufruf von RBAC-06 (Transport, Timeout, Ablehnung)
|
||||
// führt zur Verweigerung, niemals zu stillschweigendem Zugriff.
|
||||
func RequireRBAC(client *rbacclient.Client, permission string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
role := r.Header.Get(requestedRoleHeader)
|
||||
if role == "" {
|
||||
http.Error(w, "zugriff verweigert: header "+requestedRoleHeader+" erforderlich", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
allowed, err := client.Authorize(r.Context(), role, permission)
|
||||
if err != nil {
|
||||
if errors.Is(err, rbacclient.ErrDenied) {
|
||||
http.Error(w, "zugriff verweigert: rolle "+role+" hat kein recht "+permission, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// RBAC-06 nicht erreichbar oder fehlerhafte Antwort:
|
||||
// fail-closed, kein Fallback auf offenen Zugriff.
|
||||
http.Error(w, "zugriff verweigert: autorisierungsdienst nicht erreichbar", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, "zugriff verweigert: rolle "+role+" hat kein recht "+permission, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
)
|
||||
|
||||
const testServiceToken = "test-service-token"
|
||||
|
||||
// fakeRBACServer simuliert Core RBAC-06 (POST /authorize): erlaubt nur
|
||||
// die genau angegebene role/permission-Kombination, verlangt den
|
||||
// Service-Token. Damit werden RequireRBAC-Aufrufe real über HTTP
|
||||
// getestet, ohne echtes Core-Modul.
|
||||
func fakeRBACServer(t *testing.T, allowRole, allowPermission string) *rbacclient.Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Service-Token") != testServiceToken {
|
||||
http.Error(w, "kein service-token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
allowed := req.Role == allowRole && req.Permission == allowPermission
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allowed})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return rbacclient.New(server.URL, testServiceToken)
|
||||
}
|
||||
|
||||
// TestRequireRBAC_DeniedRoleReturns403 ist die vom Nutzer geforderte
|
||||
// Negativpruefung, jetzt gegen echte Policy-Rules ueber RBAC-06 (nicht
|
||||
// mehr nur ein Header-Wert wie im frueheren Provisorium).
|
||||
func TestRequireRBAC_DeniedRoleReturns403(t *testing.T) {
|
||||
client := fakeRBACServer(t, "archive_admin", "retention.configure")
|
||||
handler := RequireRBAC(client, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Kein Rollen-Header.
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("ohne rollen-header: status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Falsche Rolle - RBAC-06 lehnt real ab.
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
req.Header.Set(requestedRoleHeader, "irgendwas_anderes")
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("mit falscher rolle: status = %d, want 403", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireRBAC_AllowedRoleGrantsAccess ist der positive Gegentest,
|
||||
// real gegen die fake-RBAC-06-Antwort geprueft.
|
||||
func TestRequireRBAC_AllowedRoleGrantsAccess(t *testing.T) {
|
||||
client := fakeRBACServer(t, "archive_admin", "retention.configure")
|
||||
handler := RequireRBAC(client, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
req.Header.Set(requestedRoleHeader, "archive_admin")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mit korrekter rolle: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireRBAC_UnreachableRBACFailsClosed ist die geforderte
|
||||
// Pflichtpruefung: Ausfall von RBAC-06 fuehrt zur Ablehnung, kein
|
||||
// Fallback auf offenen Zugriff.
|
||||
func TestRequireRBAC_UnreachableRBACFailsClosed(t *testing.T) {
|
||||
unreachable := rbacclient.New("http://127.0.0.1:1", testServiceToken)
|
||||
called := false
|
||||
handler := RequireRBAC(unreachable, "retention.configure", func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
req.Header.Set(requestedRoleHeader, "archive_admin")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("bei nicht erreichbarem rbac-06: status = %d, want 403 (fail-closed)", resp.StatusCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler haette bei nicht erreichbarem rbac-06 nie erreicht werden duerfen")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
// retentionConfigurePermission ist das bei Core RBAC-02/RBAC-06
|
||||
// geprüfte Recht für alle RET-06-API-Endpunkte.
|
||||
const retentionConfigurePermission = "retention.configure"
|
||||
|
||||
// Mount registriert alle RET-06-API-Endpunkte auf mux, jeweils hinter
|
||||
// RequireRBAC (RET-08, echter RBAC-06-Aufruf) — Akzeptanzkriterium 3:
|
||||
// Änderungen an Fristen sind nur berechtigten Rollen zugänglich.
|
||||
func Mount(mux *http.ServeMux, pool *pgxpool.Pool, rbac *rbacclient.Client) {
|
||||
mux.HandleFunc("POST /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, configureHandler(pool)))
|
||||
mux.HandleFunc("POST /retention-classes/{class}/deactivate", RequireRBAC(rbac, retentionConfigurePermission, deactivateHandler(pool)))
|
||||
mux.HandleFunc("GET /retention-classes", RequireRBAC(rbac, retentionConfigurePermission, listHandler(pool)))
|
||||
mux.HandleFunc("GET /retention-classes/preview", RequireRBAC(rbac, retentionConfigurePermission, previewHandler(pool)))
|
||||
}
|
||||
|
||||
type configureRequest struct {
|
||||
RetentionClass string `json:"retention_class"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// configureHandler: Aufbewahrungsklasse anlegen ODER ändern
|
||||
// (Akzeptanzkriterium 1) — `retentionengine.ConfigureClassRule` ist ein
|
||||
// UPSERT, eine Änderung wirkt erst ab jetzt auf künftige
|
||||
// Stichtagsberechnungen (Pflichtprüfung: nicht rückwirkend).
|
||||
func configureHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req configureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.RetentionClass == "" || req.Duration == "" {
|
||||
http.Error(w, "retention_class und duration sind pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := retentionengine.ConfigureClassRule(r.Context(), pool, req.RetentionClass, req.Duration); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// deactivateHandler: Aufbewahrungsklasse deaktivieren (Akzeptanzkriterium 1).
|
||||
func deactivateHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
class := r.PathValue("class")
|
||||
if err := retentionengine.DeactivateClassRule(r.Context(), pool, class); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// listHandler liefert alle konfigurierten Aufbewahrungsklassen (aktiv
|
||||
// und deaktiviert) — Grundlage der künftigen Konfigurationsoberfläche.
|
||||
func listHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := retentionengine.ListClassRules(r.Context(), pool)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rules)
|
||||
}
|
||||
}
|
||||
|
||||
// previewHandler liefert die Vorschauliste bald ablaufender Objekte
|
||||
// (Akzeptanzkriterium 2: Standard 30 Tage, per `days`-Query-Parameter
|
||||
// überschreibbar). Nutzt DIESELBE `ListExpiringObjects`-Funktion wie
|
||||
// der periodische Job (RET-02) — Pflichtprüfung: Vorschauliste stimmt
|
||||
// mit dem Ergebnis des periodischen Jobs überein (keine zweite,
|
||||
// abweichende Implementierung).
|
||||
func previewHandler(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
days := 30
|
||||
asOf := time.Now().UTC().AddDate(0, 0, days)
|
||||
|
||||
objects, err := retentionengine.ListExpiringObjects(r.Context(), pool, asOf)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(objects)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package retentionapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
|
||||
)
|
||||
|
||||
func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func authedRequest(t *testing.T, method, url string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("request erstellen: %v", err)
|
||||
}
|
||||
req.Header.Set(requestedRoleHeader, "archive_admin")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request senden: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestConfigureAndListHandler_RealHTTPRoundTrip: Klasse anlegen, ändern,
|
||||
// über die Liste sichtbar - Akzeptanzkriterium 1.
|
||||
func TestConfigureAndListHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
body, _ := json.Marshal(configureRequest{RetentionClass: "klasse-api", Duration: "5 years"})
|
||||
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes", body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("anlegen: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
listResp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes", nil)
|
||||
defer func() { _ = listResp.Body.Close() }()
|
||||
var rules []retentionengine.ClassRule
|
||||
if err := json.NewDecoder(listResp.Body).Decode(&rules); err != nil {
|
||||
t.Fatalf("liste dekodieren: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].RetentionClass != "klasse-api" || !rules[0].Active {
|
||||
t.Fatalf("unerwartete liste: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeactivateHandler_RealHTTPRoundTrip: Deaktivierung wirkt real.
|
||||
func TestDeactivateHandler_RealHTTPRoundTrip(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deakt", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resp := authedRequest(t, http.MethodPost, server.URL+"/retention-classes/klasse-deakt/deactivate", nil)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("deaktivieren: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
rules, err := retentionengine.ListClassRules(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listclassrules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Active {
|
||||
t.Fatalf("erwartet deaktivierte klasse, habe %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewHandler_MatchesPeriodicJobResult ist die geforderte
|
||||
// Pflichtpruefung: Vorschauliste stimmt mit dem Ergebnis des
|
||||
// periodischen Jobs ueberein - beide nutzen dieselbe Funktion, real
|
||||
// per HTTP UND direkt verglichen.
|
||||
func TestPreviewHandler_MatchesPeriodicJobResult(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-preview", "1 day"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var objID string
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'preview-doc') RETURNING id`).Scan(&objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class) VALUES ($1, 'klasse-preview')`, objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
Mount(mux, pool, fakeRBACServer(t, "archive_admin", retentionConfigurePermission))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resp := authedRequest(t, http.MethodGet, server.URL+"/retention-classes/preview", nil)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var httpResult []retentionengine.ExpiringObject
|
||||
if err := json.NewDecoder(resp.Body).Decode(&httpResult); err != nil {
|
||||
t.Fatalf("preview-antwort dekodieren: %v", err)
|
||||
}
|
||||
|
||||
directResult, err := retentionengine.ListExpiringObjects(ctx, pool, time.Now().UTC().AddDate(0, 0, 30))
|
||||
if err != nil {
|
||||
t.Fatalf("listexpiringobjects direkt: %v", err)
|
||||
}
|
||||
|
||||
if len(httpResult) != len(directResult) || len(httpResult) != 1 {
|
||||
t.Fatalf("http-vorschau (%d) und periodischer job (%d) stimmen nicht ueberein", len(httpResult), len(directResult))
|
||||
}
|
||||
if httpResult[0].RetentionObjectID != directResult[0].RetentionObjectID {
|
||||
t.Fatalf("http-vorschau und periodischer job liefern unterschiedliche objekte: %+v vs %+v", httpResult[0], directResult[0])
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,12 @@ func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass,
|
||||
// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive
|
||||
// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von
|
||||
// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte.
|
||||
// Nur AKTIVE Regeln werden verwendet (siehe DeactivateClassRule).
|
||||
func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) {
|
||||
var due time.Time
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT $1::timestamptz + r.duration
|
||||
FROM retention_class_rules r WHERE r.retention_class = $2
|
||||
FROM retention_class_rules r WHERE r.retention_class = $2 AND r.active
|
||||
`, start, retentionClass).Scan(&due)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err)
|
||||
@@ -45,6 +46,51 @@ func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, re
|
||||
return due, nil
|
||||
}
|
||||
|
||||
// DeactivateClassRule (RET-06): eine Aufbewahrungsklasse wird deaktiviert,
|
||||
// OHNE ihre Historie (bereits erfolgte Zuordnungen/Berechnungen) zu
|
||||
// verlieren — kein DELETE. Deaktivierte Klassen fließen nicht mehr in
|
||||
// ComputeDueDate/ListExpiringObjects ein, ändern aber nichts an bereits
|
||||
// getroffenen Berechnungen (Pflichtprüfung: Änderung wirkt nur auf
|
||||
// künftige Berechnungen, nicht rückwirkend).
|
||||
func DeactivateClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass string) error {
|
||||
tag, err := pool.Exec(ctx, `UPDATE retention_class_rules SET active = false WHERE retention_class = $1`, retentionClass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retentionengine: klasse deaktivieren: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("retentionengine: unbekannte aufbewahrungsklasse %q", retentionClass)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassRule ist EINE konfigurierte Aufbewahrungsklasse mit Frist und
|
||||
// Aktiv-Status.
|
||||
type ClassRule struct {
|
||||
RetentionClass string
|
||||
Duration string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ListClassRules liefert alle konfigurierten Aufbewahrungsklassen
|
||||
// (aktiv und deaktiviert) — Grundlage für die Konfigurationsoberfläche.
|
||||
func ListClassRules(ctx context.Context, pool *pgxpool.Pool) ([]ClassRule, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT retention_class, duration::text, active FROM retention_class_rules ORDER BY retention_class`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retentionengine: aufbewahrungsklassen auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []ClassRule
|
||||
for rows.Next() {
|
||||
var r ClassRule
|
||||
if err := rows.Scan(&r.RetentionClass, &r.Duration, &r.Active); err != nil {
|
||||
return nil, fmt.Errorf("retentionengine: klassen-zeile lesen: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist.
|
||||
type ExpiringObject struct {
|
||||
RetentionObjectID string
|
||||
@@ -75,7 +121,7 @@ func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time
|
||||
a.assigned_at + r.duration AS due_date
|
||||
FROM retention_objects o
|
||||
JOIN latest_assignment a ON a.retention_object_id = o.id
|
||||
JOIN retention_class_rules r ON r.retention_class = a.retention_class
|
||||
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
|
||||
WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1
|
||||
ORDER BY due_date ASC
|
||||
`, asOf)
|
||||
|
||||
@@ -37,7 +37,8 @@ func requireTestPool(t *testing.T) *pgxpool.Pool {
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
@@ -105,6 +106,66 @@ func TestComputeDueDate_KnownReferenceDates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations ist
|
||||
// Pruefung fuer RET-06: eine Fristaenderung wirkt sich nur auf
|
||||
// KUENFTIGE Berechnungen aus, nicht rueckwirkend - real bewiesen, indem
|
||||
// ein bereits berechneter Stichtag (in einer lokalen Variable, dem
|
||||
// einzigen Ort, an dem ein "bereits berechnetes" Ergebnis ueberhaupt
|
||||
// existiert - es gibt keine Tabelle, die rueckwirkend veraendert werden
|
||||
// koennte) nach der Regelaenderung unveraendert bleibt, waehrend eine
|
||||
// NEUE Berechnung fuer dieselbe Klasse die NEUE Frist verwendet.
|
||||
func TestConfigureClassRule_ChangeAppliesOnlyToFutureCalculations(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
start := mustTime(t, "2006-01-02", "2026-01-01")
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
|
||||
if err != nil {
|
||||
t.Fatalf("erste berechnung: %v", err)
|
||||
}
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-aenderung", "2 years"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Der bereits berechnete Wert (before) bleibt unveraendert - er ist
|
||||
// eine lokale Kopie, es existiert keine Tabelle, die eine
|
||||
// nachtraegliche "Umschreibung" ermoeglichen wuerde.
|
||||
if !before.Equal(mustTime(t, "2006-01-02", "2027-01-01")) {
|
||||
t.Fatalf("bereits berechneter stichtag veraendert: %v", before)
|
||||
}
|
||||
|
||||
after, err := ComputeDueDate(ctx, pool, start, "klasse-aenderung")
|
||||
if err != nil {
|
||||
t.Fatalf("zweite berechnung: %v", err)
|
||||
}
|
||||
if !after.Equal(mustTime(t, "2006-01-02", "2028-01-01")) {
|
||||
t.Fatalf("neue berechnung uebernimmt neue frist nicht: %v", after)
|
||||
}
|
||||
if before.Equal(after) {
|
||||
t.Fatal("neue frist haette eine andere berechnung liefern muessen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeactivateClassRule_ExcludesFromFutureCalculations.
|
||||
func TestDeactivateClassRule_ExcludesFromFutureCalculations(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ConfigureClassRule(ctx, pool, "klasse-deakt-eng", "1 year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DeactivateClassRule(ctx, pool, "klasse-deakt-eng"); err != nil {
|
||||
t.Fatalf("deactivateclassrule: %v", err)
|
||||
}
|
||||
if _, err := ComputeDueDate(ctx, pool, time.Now(), "klasse-deakt-eng"); err == nil {
|
||||
t.Fatal("erwartet fehler: deaktivierte klasse darf nicht mehr verwendet werden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2.
|
||||
func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS active;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- RET-06-API: Aufbewahrungsklassen lassen sich deaktivieren, ohne ihre
|
||||
-- Historie (bereits erfolgte Zuordnungen/Berechnungen) zu verlieren -
|
||||
-- kein DELETE, nur ein Sichtbarkeits-/Anwendbarkeits-Flag.
|
||||
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=NEXARCH Archive - Aufbewahrungsfristen-Konfigurations-API (RET-06-API)
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nexarch
|
||||
EnvironmentFile=/etc/nexarch/archive-retention-api.env
|
||||
ExecStart=__INSTALL_DIR__/bin/retention-api
|
||||
Restart=on-failure
|
||||
StandardOutput=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user