DMS2-03: Externe-Backup-Ziel-Anbindung #229
No files matched your search
@@ -0,0 +1,103 @@
|
||||
# DMS2-03 – Prüfprotokoll: Externe-Backup-Ziel-Anbindung
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Neues Paket `connect/internal/backupconnector`: Connector für externe,
|
||||
S3-kompatible Backup-Ziele, nach demselben `registry.Connector`-Muster wie
|
||||
`erpconnector`/`crmconnector`/`m365connector`. Verschlüsselung nutzt die
|
||||
bestehende Envelope-Encryption aus `connect/internal/crypto` (frischer DEK
|
||||
je Export, verpackt mit dem Tenant-KEK) statt einer zweiten
|
||||
Krypto-Implementierung. Das exportierte Format (`Envelope`: gewrappter DEK +
|
||||
Ciphertext, beide Base64 in JSON) ist offen dokumentiert und ohne
|
||||
NEXARCH-Werkzeug lesbar, sobald der Tenant-KEK vorliegt – kein
|
||||
geschlossenes Migrationsformat.
|
||||
|
||||
Retry bei fehlgeschlagenem Export läuft über die bereits bestehende
|
||||
Fehlerbehandlung aus `connect/internal/syncjob` (ENG-05,
|
||||
`CompleteRunWithRetry`/`ClaimDueRetries`) – keine zweite, neu erfundene
|
||||
Retry-Schleife. `backupconnector` liefert dafür nur einen normalen Go-Error
|
||||
zurück.
|
||||
|
||||
Zugangsdaten (Endpoint, Bucket, Region, AccessKeyID/-SecretKey) werden
|
||||
ausschließlich über `Config`-Felder hereingereicht, die der Aufrufer aus
|
||||
Umgebungsvariablen befüllt – keine Zugangsdaten im Code. Sichere Speicherung
|
||||
dieser Zugangsdaten ist QA-04 (nicht Teil dieser Kachel); dieses Paket hält
|
||||
lediglich `Config` als Schnittstelle dafür bereit.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
### Prüfung 1 – Verbindungsaufbau zu Test-Backup-Ziel im Test erfolgreich
|
||||
|
||||
Zielwert: `Client.Connect` gegen ein Test-Backup-Ziel meldet Erfolg;
|
||||
`Client` erfüllt `registry.Connector` und lässt sich in die Registry
|
||||
einhängen.
|
||||
|
||||
Ergebnis: erfüllt. `TestClient_ImplementsRegistryConnectorAndConnects`
|
||||
registriert den Connector in einer `registry.Registry` und ruft `Connect`
|
||||
gegen einen `httptest.Server` (S3-kompatibles Test-Double) auf – Erfolg,
|
||||
genau ein HEAD-Aufruf mit korrekt geformtem SigV4-`Authorization`-Header.
|
||||
`TestClient_ConnectFailsOnUnreachableTarget` zeigt zusätzlich den
|
||||
Fehlerfall (nicht erreichbares Ziel → `ErrConnectionFailed`).
|
||||
|
||||
Da für S3-kompatiblen Speicher keine echten Cloud-Zugangsdaten zur
|
||||
Verfügung stehen, dient ein `httptest`-Mock als Test-Backup-Ziel (gleiche
|
||||
Einschränkung wie bei M365-01's OAuth2-Mock-Tests, hier ehrlich
|
||||
dokumentiert statt die Prüfung auszulassen). Der Mock verifiziert das
|
||||
Vorhandensein und Format des AWS4-HMAC-SHA256-Headers sowie der
|
||||
`x-amz-date`/`x-amz-content-sha256`-Header; eine volle
|
||||
Signatur-Neuberechnung würde eine AWS-SDK-Abhängigkeit voraussetzen, die
|
||||
Connect bewusst nicht hat.
|
||||
|
||||
### Prüfung 2 – Übertragung im Test nachweislich verschlüsselt
|
||||
|
||||
Zielwert: die auf der Leitung ankommende Nutzlast enthält das
|
||||
Klartext-Dokument an keiner Stelle; mit dem korrekten Tenant-KEK lässt sie
|
||||
sich wieder zum Original entschlüsseln, mit einem falschen KEK schlägt die
|
||||
Entschlüsselung fehl.
|
||||
|
||||
Ergebnis: erfüllt. `TestClient_ExportIsEncryptedInTransit` exportiert einen
|
||||
(synthetischen, nicht echten) Testdokumenttext, liest die vom Mock-Server
|
||||
tatsächlich empfangenen Rohbytes, prüft `bytes.Contains` sowohl auf den
|
||||
rohen Draht-Payload als auch auf den decodierten Ciphertext (in beiden
|
||||
Fällen: nicht enthalten), entschlüsselt anschließend mit dem korrekten KEK
|
||||
zurück zum Original (Erfolg) und mit einem falschen KEK (`ErrDecryptFailed`
|
||||
über `crypto.Service.Open`).
|
||||
|
||||
### Prüfung 3 – Simulierter Übertragungsfehler löst Retry aus
|
||||
|
||||
Zielwert: ein fehlgeschlagener Export führt über die bestehende
|
||||
Fehlerbehandlung (ENG-05 `syncjob`) zu einem geplanten Retry, der nach
|
||||
Ablauf der Backoff-Zeit erneut ausgeführt wird und danach erfolgreich
|
||||
abschließt.
|
||||
|
||||
Ergebnis: erfüllt. `TestExport_TransientFailureTriggersRetryViaSyncjob`
|
||||
lässt den Mock-Server den ersten Übertragungsversuch mit
|
||||
503 Service Unavailable ablehnen, ruft `syncjob.Store.CompleteRunWithRetry`
|
||||
mit dem daraus resultierenden Fehler auf (Status → `retry_pending`),
|
||||
bestätigt über `ClaimDueRetries`, dass der Retry nach Ablauf der Backoff-Zeit
|
||||
abholbar ist, wiederholt den Export (jetzt ohne simulierten Ausfall,
|
||||
erfolgreich) und schließt den Retry-Run über `CompleteRun` erfolgreich ab.
|
||||
Integrationstest gegen echtes Postgres (`TEST_TENANT_DSN`), eigenes
|
||||
Wegwerf-Schema pro Testlauf über `testdb.Pool`.
|
||||
|
||||
## Testausführung
|
||||
|
||||
```
|
||||
TEST_TENANT_DSN="postgres://postgres:postgres@localhost:5432/dms2_03_backup_test?sslmode=disable" \
|
||||
go test ./internal/backupconnector/... -v
|
||||
```
|
||||
|
||||
Ausgeführt auf Testhost 192.168.1.204, eigene Wegwerf-Datenbank
|
||||
`dms2_03_backup_test` (nicht `archivdms`, nicht Host 131). Alle 6 Tests
|
||||
grün. `go build ./...` und `go vet ./...` für das gesamte `connect`-Modul
|
||||
ebenfalls ohne Befund.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- QA-04 (Sicherheitsprüfung Drittsystem-Credentials) ist bewusst nicht Teil
|
||||
dieser Kachel; `Config` hält Endpoint/Bucket/Region/AccessKeyID/
|
||||
AccessSecretKey als reine Werte bereit, eine sichere Ablage (z. B. über
|
||||
`credential.Store`) ist dort zu ergänzen.
|
||||
- Kein echter SigV4-Abgleich gegen eine reale S3-Implementierung (MinIO/AWS)
|
||||
durchgeführt, da keine echten Zugangsdaten verfügbar sind – nur
|
||||
Header-Vorhandensein/-Format gegen das Mock-Ziel geprüft.
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package backupconnector implements the external-backup-target connector
|
||||
// (DMS2-03): export of document archives to S3-compatible object storage,
|
||||
// encrypted client-side before transfer. Follows the same registry.Connector
|
||||
// plugin pattern as erpconnector/crmconnector/m365connector — a backup
|
||||
// target is one connector under the same interface, not a bolted-on special
|
||||
// case. Encryption reuses connect/internal/crypto's envelope-encryption
|
||||
// service (fresh DEK per export, wrapped with the tenant KEK) rather than a
|
||||
// second crypto implementation. Retry-on-failure (AC3) reuses syncjob's
|
||||
// existing CompleteRunWithRetry/ClaimDueRetries (ENG-05) instead of a second
|
||||
// retry loop; this package only has to return a plain error on failure.
|
||||
//
|
||||
// The exported envelope is plain JSON (wrapped DEK + ciphertext, both
|
||||
// base64), documented here and in DMS2-03-PRUEFPROTOKOLL.md, deliberately
|
||||
// not a closed/proprietary format — any tool that knows the tenant KEK can
|
||||
// decrypt without NEXARCH-specific tooling.
|
||||
package backupconnector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrConnectionFailed wraps any failure to reach the backup target.
|
||||
ErrConnectionFailed = errors.New("backupconnector: connection failed")
|
||||
// ErrExportFailed wraps any failure of an encrypted export upload.
|
||||
ErrExportFailed = errors.New("backupconnector: export failed")
|
||||
)
|
||||
|
||||
// Config configures one external-backup-target connector instance.
|
||||
// Endpoint, Bucket, Region, AccessKeyID and AccessSecretKey are read from
|
||||
// environment variables by the caller, never hardcoded (QA-04 covers
|
||||
// hardening how they are stored; this package only consumes them).
|
||||
type Config struct {
|
||||
Name string
|
||||
Version string
|
||||
|
||||
Endpoint string // e.g. "https://backup.example.com", S3-compatible, path-style
|
||||
Bucket string
|
||||
Region string
|
||||
AccessKeyID string
|
||||
AccessSecretKey string
|
||||
}
|
||||
|
||||
// Envelope is the open, documented on-the-wire export format: a fresh
|
||||
// data-encryption key wrapped with the tenant KEK, plus the ciphertext it
|
||||
// protects. Readable by any tool that has the tenant KEK, without
|
||||
// NEXARCH-specific tooling.
|
||||
type Envelope struct {
|
||||
WrappedDEK []byte `json:"wrapped_dek_b64"`
|
||||
Ciphertext []byte `json:"ciphertext_b64"`
|
||||
}
|
||||
|
||||
// Client is the external-backup-target connector. It implements
|
||||
// registry.Connector so it can be registered under ENG-01's plugin model.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
crypto *crypto.Service
|
||||
http *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns a Client for cfg, encrypting exports via cryptoSvc.
|
||||
func New(cfg Config, cryptoSvc *crypto.Service) *Client {
|
||||
return &Client{cfg: cfg, crypto: cryptoSvc, http: httpclient.Default(), now: time.Now}
|
||||
}
|
||||
|
||||
func (c *Client) Metadata() registry.Metadata {
|
||||
return registry.Metadata{
|
||||
Name: c.cfg.Name,
|
||||
Version: c.cfg.Version,
|
||||
Operations: []string{"connect", "export"},
|
||||
}
|
||||
}
|
||||
|
||||
// Connect verifies the backup target is reachable and the configured
|
||||
// credentials are accepted, via a signed HEAD request against the
|
||||
// configured bucket.
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
req, err := c.signedRequest(ctx, http.MethodHead, "/"+c.cfg.Bucket, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrConnectionFailed, err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrConnectionFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Export encrypts plaintext under a fresh DEK wrapped with the tenant's KEK
|
||||
// (AC2: the transfer body is always the encrypted Envelope, never
|
||||
// plaintext), and uploads it to the backup target under objectKey via a
|
||||
// signed PUT. A returned error is a plain error, not yet retried — the
|
||||
// caller (a syncjob run) decides via syncjob.Store.CompleteRunWithRetry
|
||||
// whether and when to retry (AC3), so this package does not duplicate that
|
||||
// error handling.
|
||||
func (c *Client) Export(ctx context.Context, tenantSlug, objectKey string, plaintext []byte) error {
|
||||
sealed, err := c.crypto.Seal(ctx, tenantSlug, plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encrypt: %w", ErrExportFailed, err)
|
||||
}
|
||||
envelope := Envelope{
|
||||
WrappedDEK: sealed.WrappedDEK,
|
||||
Ciphertext: sealed.Ciphertext,
|
||||
}
|
||||
body, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: marshal envelope: %w", ErrExportFailed, err)
|
||||
}
|
||||
|
||||
path := "/" + c.cfg.Bucket + "/" + strings.TrimPrefix(objectKey, "/")
|
||||
req, err := c.signedRequest(ctx, http.MethodPut, path, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrExportFailed, err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrExportFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("%w: status %d: %s", ErrExportFailed, resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeEnvelope parses the open export format back out of raw object
|
||||
// bytes, for restore or for any external tool reading the backup target.
|
||||
func DecodeEnvelope(raw []byte) (Envelope, error) {
|
||||
var e Envelope
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
return Envelope{}, fmt.Errorf("backupconnector: decode envelope: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (c *Client) signedRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.cfg.Endpoint+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.ContentLength = int64(len(body))
|
||||
}
|
||||
signRequest(req, body, c.cfg.AccessKeyID, c.cfg.AccessSecretKey, c.cfg.Region, "s3", c.now())
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package backupconnector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 }
|
||||
|
||||
func (f *fakeKEK) TenantKEK(_ context.Context, _ string) ([]byte, error) { return f.key, nil }
|
||||
|
||||
func newTestKEK() *fakeKEK {
|
||||
key := make([]byte, crypto.KEKSize)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 7)
|
||||
}
|
||||
return &fakeKEK{key: key}
|
||||
}
|
||||
|
||||
// mockS3 is a minimal S3-compatible test double: it requires a well-formed
|
||||
// AWS4-HMAC-SHA256 Authorization header (real SigV4 verification would need
|
||||
// an AWS SDK we deliberately do not depend on), records the raw bytes it
|
||||
// received on the wire, and can be told to fail the first N requests to
|
||||
// simulate a transient outage. No real cloud credentials are used or
|
||||
// available on this host - this stands in for the real S3-compatible target,
|
||||
// same limitation as M365-01's OAuth2 mock testing.
|
||||
type mockS3 struct {
|
||||
failNext atomic.Int32
|
||||
lastBody atomic.Value // []byte
|
||||
lastAuth atomic.Value // string
|
||||
callCount atomic.Int32
|
||||
forcedCode int
|
||||
}
|
||||
|
||||
func newMockS3() *mockS3 { return &mockS3{} }
|
||||
|
||||
func (m *mockS3) handler(t *testing.T) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.callCount.Add(1)
|
||||
auth := r.Header.Get("Authorization")
|
||||
m.lastAuth.Store(auth)
|
||||
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=") {
|
||||
t.Errorf("missing/malformed SigV4 Authorization header: %q", auth)
|
||||
}
|
||||
if r.Header.Get("x-amz-date") == "" || r.Header.Get("x-amz-content-sha256") == "" {
|
||||
t.Errorf("missing SigV4 headers")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
m.lastBody.Store(body)
|
||||
|
||||
if m.failNext.Load() > 0 {
|
||||
m.failNext.Add(-1)
|
||||
code := m.forcedCode
|
||||
if code == 0 {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(endpoint string) Config {
|
||||
return Config{
|
||||
Name: "backup-test", Version: "1.0.0",
|
||||
Endpoint: endpoint, Bucket: "nexarch-backup", Region: "eu-central-1",
|
||||
AccessKeyID: "test-key", AccessSecretKey: "test-secret",
|
||||
}
|
||||
}
|
||||
|
||||
// AC1 / Prüfung 1: Backup-Ziel richtet sich über das Connector-Plugin-Modell
|
||||
// ein und der Verbindungsaufbau zum Test-Backup-Ziel gelingt.
|
||||
func TestClient_ImplementsRegistryConnectorAndConnects(t *testing.T) {
|
||||
mock := newMockS3()
|
||||
server := httptest.NewServer(mock.handler(t))
|
||||
defer server.Close()
|
||||
|
||||
c := New(testConfig(server.URL), crypto.NewService(newTestKEK()))
|
||||
|
||||
var _ registry.Connector = c
|
||||
md := c.Metadata()
|
||||
if md.Name != "backup-test" || md.Version != "1.0.0" {
|
||||
t.Fatalf("metadata = %+v", md)
|
||||
}
|
||||
if len(md.Operations) == 0 {
|
||||
t.Fatalf("no operations declared")
|
||||
}
|
||||
|
||||
reg := registry.New()
|
||||
if err := reg.Register(c); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("connect to test backup target: %v", err)
|
||||
}
|
||||
if mock.callCount.Load() != 1 {
|
||||
t.Fatalf("call count = %d, want 1", mock.callCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ConnectFailsOnUnreachableTarget(t *testing.T) {
|
||||
c := New(testConfig("http://127.0.0.1:1"), crypto.NewService(newTestKEK()))
|
||||
if err := c.Connect(context.Background()); err == nil {
|
||||
t.Fatalf("expected connection error")
|
||||
} else if !errors.Is(err, ErrConnectionFailed) {
|
||||
t.Fatalf("err = %v, want ErrConnectionFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2 / Prüfung 2: Übertragung ist nachweislich verschlüsselt - die auf der
|
||||
// Leitung ankommende Nutzlast enthält weder das Klartext-Dokument noch
|
||||
// erlaubt sie dessen Wiederherstellung ohne den Tenant-KEK, und dieselbe
|
||||
// Nutzlast lässt sich mit dem korrekten KEK wieder zum Original entschlüsseln.
|
||||
func TestClient_ExportIsEncryptedInTransit(t *testing.T) {
|
||||
mock := newMockS3()
|
||||
server := httptest.NewServer(mock.handler(t))
|
||||
defer server.Close()
|
||||
|
||||
kek := newTestKEK()
|
||||
svc := crypto.NewService(kek)
|
||||
c := New(testConfig(server.URL), svc)
|
||||
|
||||
plaintext := []byte("streng vertrauliches Testdokument, DSGVO-relevant (synthetisch)")
|
||||
if err := c.Export(context.Background(), "acme", "docs/2026/testdoc.pdf", plaintext); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
|
||||
wire, _ := mock.lastBody.Load().([]byte)
|
||||
if len(wire) == 0 {
|
||||
t.Fatalf("no body received by backup target")
|
||||
}
|
||||
if bytes.Contains(wire, plaintext) {
|
||||
t.Fatalf("plaintext found verbatim in wire payload - not encrypted")
|
||||
}
|
||||
|
||||
env, err := DecodeEnvelope(wire)
|
||||
if err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
if bytes.Contains(env.Ciphertext, plaintext) {
|
||||
t.Fatalf("ciphertext contains plaintext verbatim")
|
||||
}
|
||||
|
||||
opened, err := svc.Open(context.Background(), "acme", &crypto.Sealed{
|
||||
Ciphertext: env.Ciphertext, WrappedDEK: env.WrappedDEK,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open with correct kek: %v", err)
|
||||
}
|
||||
if !bytes.Equal(opened, plaintext) {
|
||||
t.Fatalf("decrypted = %q, want %q", opened, plaintext)
|
||||
}
|
||||
|
||||
wrongKEK := &fakeKEK{key: make([]byte, crypto.KEKSize)}
|
||||
wrongSvc := crypto.NewService(wrongKEK)
|
||||
if _, err := wrongSvc.Open(context.Background(), "acme", &crypto.Sealed{
|
||||
Ciphertext: env.Ciphertext, WrappedDEK: env.WrappedDEK,
|
||||
}); err == nil {
|
||||
t.Fatalf("expected decrypt failure with wrong kek")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ExportFailsOnServerError(t *testing.T) {
|
||||
mock := newMockS3()
|
||||
mock.failNext.Store(100)
|
||||
server := httptest.NewServer(mock.handler(t))
|
||||
defer server.Close()
|
||||
|
||||
c := New(testConfig(server.URL), crypto.NewService(newTestKEK()))
|
||||
err := c.Export(context.Background(), "acme", "docs/x.pdf", []byte("x"))
|
||||
if !errors.Is(err, ErrExportFailed) {
|
||||
t.Fatalf("err = %v, want ErrExportFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEnvelope_RoundTrip(t *testing.T) {
|
||||
raw := []byte(`{"wrapped_dek_b64":"YWJj","ciphertext_b64":"eHl6"}`)
|
||||
env, err := DecodeEnvelope(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if string(env.WrappedDEK) != "abc" || string(env.Ciphertext) != "xyz" {
|
||||
t.Fatalf("env = %+v", env)
|
||||
}
|
||||
back, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(back), "wrapped_dek_b64") {
|
||||
t.Fatalf("re-marshal lost fields: %s", back)
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 / Prüfung 3: ein simulierter Übertragungsfehler löst einen Retry aus -
|
||||
// über die bereits bestehende Fehlerbehandlung (ENG-05 syncjob.CompleteRunWithRetry),
|
||||
// nicht über eine zweite, neu erfundene Retry-Schleife.
|
||||
func TestExport_TransientFailureTriggersRetryViaSyncjob(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
root, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis: %v", err)
|
||||
}
|
||||
migrations, err := migrate.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
store := syncjob.NewStore(pool)
|
||||
|
||||
mock := newMockS3()
|
||||
mock.failNext.Store(1) // first attempt fails, simulating a transient outage
|
||||
server := httptest.NewServer(mock.handler(t))
|
||||
defer server.Close()
|
||||
|
||||
c := New(testConfig(server.URL), crypto.NewService(newTestKEK()))
|
||||
|
||||
ref := syncjob.Ref{TenantSlug: "acme", ConnectorName: "backup-test", ConnectorInstanceID: t.Name()}
|
||||
jobID, err := store.Create(context.Background(), ref, 3600)
|
||||
if err != nil {
|
||||
t.Fatalf("create job: %v", err)
|
||||
}
|
||||
run, err := store.TriggerNow(context.Background(), jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("trigger now: %v", err)
|
||||
}
|
||||
|
||||
exportErr := c.Export(context.Background(), "acme", "docs/retry-test.pdf", []byte("payload"))
|
||||
if exportErr == nil {
|
||||
t.Fatalf("expected export to fail on the simulated outage")
|
||||
}
|
||||
|
||||
policy := syncjob.RetryPolicy{MaxAttempts: 3, BaseDelay: time.Millisecond, MaxDelay: time.Second}
|
||||
now := time.Now()
|
||||
status, err := store.CompleteRunWithRetry(context.Background(), run.ID, exportErr, policy, now)
|
||||
if err != nil {
|
||||
t.Fatalf("complete run with retry: %v", err)
|
||||
}
|
||||
if status != syncjob.RunStatusRetryPending {
|
||||
t.Fatalf("status = %q, want retry_pending", status)
|
||||
}
|
||||
|
||||
due, err := store.ClaimDueRetries(context.Background(), now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("claim due retries: %v", err)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("claimed %d retries, want 1", len(due))
|
||||
}
|
||||
|
||||
retryErr := c.Export(context.Background(), "acme", "docs/retry-test.pdf", []byte("payload"))
|
||||
if retryErr != nil {
|
||||
t.Fatalf("retried export should succeed once the outage is over: %v", retryErr)
|
||||
}
|
||||
if err := store.CompleteRun(context.Background(), due[0].ID, nil); err != nil {
|
||||
t.Fatalf("complete retried run: %v", err)
|
||||
}
|
||||
final, err := store.GetRun(context.Background(), due[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get retried run: %v", err)
|
||||
}
|
||||
if final.Status != syncjob.RunStatusSucceeded {
|
||||
t.Fatalf("retried run status = %q, want succeeded", final.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// AWS Signature Version 4 request signing, the auth scheme S3-compatible
|
||||
// object storage (AWS S3, MinIO, Wasabi, ...) expects. Minimal single-request
|
||||
// signer, no SDK dependency (Connect has none), covering exactly the PUT/HEAD
|
||||
// calls backupconnector issues.
|
||||
package backupconnector
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const awsV4Algorithm = "AWS4-HMAC-SHA256"
|
||||
|
||||
func hmacSHA256(key, data []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func signingKey(secretAccessKey, date, region, service string) []byte {
|
||||
kDate := hmacSHA256([]byte("AWS4"+secretAccessKey), []byte(date))
|
||||
kRegion := hmacSHA256(kDate, []byte(region))
|
||||
kService := hmacSHA256(kRegion, []byte(service))
|
||||
return hmacSHA256(kService, []byte("aws4_request"))
|
||||
}
|
||||
|
||||
// signRequest adds the x-amz-date, x-amz-content-sha256 and Authorization
|
||||
// headers required for an S3-compatible request signed with accessKeyID/
|
||||
// secretAccessKey for region/service (always "s3" here), at time t.
|
||||
func signRequest(req *http.Request, body []byte, accessKeyID, secretAccessKey, region, service string, t time.Time) {
|
||||
amzDate := t.UTC().Format("20060102T150405Z")
|
||||
dateStamp := t.UTC().Format("20060102")
|
||||
payloadHash := sha256Hex(body)
|
||||
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHash)
|
||||
if req.Host == "" {
|
||||
req.Host = req.URL.Host
|
||||
}
|
||||
|
||||
signedHeaderNames := []string{"host", "x-amz-content-sha256", "x-amz-date"}
|
||||
sort.Strings(signedHeaderNames)
|
||||
var canonicalHeaders strings.Builder
|
||||
for _, name := range signedHeaderNames {
|
||||
var value string
|
||||
switch name {
|
||||
case "host":
|
||||
value = req.Host
|
||||
default:
|
||||
value = req.Header.Get(name)
|
||||
}
|
||||
canonicalHeaders.WriteString(name)
|
||||
canonicalHeaders.WriteString(":")
|
||||
canonicalHeaders.WriteString(value)
|
||||
canonicalHeaders.WriteString("\n")
|
||||
}
|
||||
signedHeaders := strings.Join(signedHeaderNames, ";")
|
||||
|
||||
canonicalURI := req.URL.EscapedPath()
|
||||
if canonicalURI == "" {
|
||||
canonicalURI = "/"
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
req.Method,
|
||||
canonicalURI,
|
||||
req.URL.RawQuery,
|
||||
canonicalHeaders.String(),
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
}, "\n")
|
||||
|
||||
credentialScope := strings.Join([]string{dateStamp, region, service, "aws4_request"}, "/")
|
||||
stringToSign := strings.Join([]string{
|
||||
awsV4Algorithm,
|
||||
amzDate,
|
||||
credentialScope,
|
||||
sha256Hex([]byte(canonicalRequest)),
|
||||
}, "\n")
|
||||
|
||||
signature := hex.EncodeToString(hmacSHA256(signingKey(secretAccessKey, dateStamp, region, service), []byte(stringToSign)))
|
||||
|
||||
authHeader := awsV4Algorithm + " Credential=" + accessKeyID + "/" + credentialScope +
|
||||
", SignedHeaders=" + signedHeaders + ", Signature=" + signature
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
Reference in new issue
Block a user