diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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= diff --git a/internal/license/license.go b/internal/license/license.go new file mode 100644 index 0000000..34dab1b --- /dev/null +++ b/internal/license/license.go @@ -0,0 +1,106 @@ +// Package license implementiert Core LIC-01: Lizenzmodell je Tenant (Plan, +// Modul-Umfang, Laufzeit) und die kryptographische Pruefung signierter +// Lizenzschluessel. Feature-Flag-AUSWERTUNG zur Laufzeit (LIC-02) und die +// Verwaltungsoberflaeche (LIC-04) sind ausdruecklich nicht Teil dieses Pakets +// — hier geht es nur um Ausstellung/Validierung/Persistenz (Unleash-Vorbild: +// klare Trennung Flag-Verwaltung vs. Flag-Auswertung). +package license + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" +) + +var ( + ErrInvalidSignature = errors.New("license: signatur ungueltig") + ErrMalformedKey = errors.New("license: lizenzschluessel hat ungueltiges format") +) + +// Payload ist der signierte Lizenzinhalt (Akzeptanzkriterium 3: Plan, +// Modul-Liste, Laufzeit). +type Payload struct { + TenantSlug string `json:"tenant_slug"` + Plan string `json:"plan"` + Modules []string `json:"modules"` + IssuedAt time.Time `json:"issued_at"` + ValidUntil time.Time `json:"valid_until"` +} + +// Issuer stellt signierte Lizenzschluessel aus. Haelt den PRIVATEN +// Ed25519-Schluessel — lebt in der Praxis beim Lizenzgeber, nicht im +// laufenden Core-Prozess (der nur den Validator mit dem oeffentlichen +// Schluessel braucht). +type Issuer struct { + priv ed25519.PrivateKey +} + +func NewIssuer(priv ed25519.PrivateKey) *Issuer { + return &Issuer{priv: priv} +} + +// Issue liefert den Lizenzschluessel im Format base64(payload-json) "." base64(signatur). +func (i *Issuer) Issue(payload Payload) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("payload serialisieren: %w", err) + } + sig := ed25519.Sign(i.priv, raw) + + return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// Validator prueft Lizenzschluessel gegen den OEFFENTLICHEN Ed25519-Schluessel +// — das ist alles, was der laufende Core-Prozess kennen muss. +type Validator struct { + pub ed25519.PublicKey +} + +func NewValidator(pub ed25519.PublicKey) *Validator { + return &Validator{pub: pub} +} + +// Parse prueft die Signatur (Akzeptanzkriterium 1 / Pruefung 1) und liefert +// bei Erfolg den entschluesselten Payload. Ein manipulierter Schluessel wird +// hier zuverlaessig erkannt, unabhaengig davon, ob die Laufzeit noch gueltig +// waere — Signaturpruefung und Ablaufpruefung sind bewusst getrennt +// (Signatur bei Einspielen, Ablauf bei jeder Nutzung, siehe Store.RequireActive). +func (v *Validator) Parse(key string) (Payload, error) { + rawPart, sigPart, ok := splitOnce(key, '.') + if !ok { + return Payload{}, ErrMalformedKey + } + + raw, err := base64.RawURLEncoding.DecodeString(rawPart) + if err != nil { + return Payload{}, ErrMalformedKey + } + sig, err := base64.RawURLEncoding.DecodeString(sigPart) + if err != nil { + return Payload{}, ErrMalformedKey + } + + if !ed25519.Verify(v.pub, raw, sig) { + return Payload{}, ErrInvalidSignature + } + + var p Payload + if err := json.Unmarshal(raw, &p); err != nil { + // Signatur war gueltig, aber Payload nicht mehr parsebar — sollte bei + // unveraenderten Schluesseln nie vorkommen, trotzdem kein Panic. + return Payload{}, fmt.Errorf("%w: payload nicht lesbar", ErrMalformedKey) + } + return p, nil +} + +func splitOnce(s string, sep byte) (before, after string, ok bool) { + for i := 0; i < len(s); i++ { + if s[i] == sep { + return s[:i], s[i+1:], true + } + } + return "", "", false +} diff --git a/internal/license/license_test.go b/internal/license/license_test.go new file mode 100644 index 0000000..fb9316a --- /dev/null +++ b/internal/license/license_test.go @@ -0,0 +1,106 @@ +package license + +import ( + "crypto/ed25519" + "errors" + "testing" + "time" +) + +func testKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("schluesselpaar erzeugen: %v", err) + } + return pub, priv +} + +func TestIssueAndParse_RoundTrip(t *testing.T) { + pub, priv := testKeyPair(t) + issuer := NewIssuer(priv) + validator := NewValidator(pub) + + payload := Payload{ + TenantSlug: "acme", + Plan: "pro", + Modules: []string{"dms", "mail"}, + IssuedAt: time.Now().Truncate(time.Second), + ValidUntil: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second), + } + + key, err := issuer.Issue(payload) + if err != nil { + t.Fatalf("issue: %v", err) + } + + got, err := validator.Parse(key) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.TenantSlug != payload.TenantSlug || got.Plan != payload.Plan || len(got.Modules) != 2 { + t.Fatalf("payload nach parse unerwartet: %+v", got) + } +} + +// Akzeptanzkriterium 1 + Pruefung 1: manipulierter Schluessel wird zuverlaessig erkannt. +func TestParse_RejectsTamperedKey(t *testing.T) { + pub, priv := testKeyPair(t) + issuer := NewIssuer(priv) + validator := NewValidator(pub) + + key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatalf("issue: %v", err) + } + + // Ein Zeichen im signierten Teil aendern. + tampered := []byte(key) + changed := false + for i, c := range tampered { + if c != '.' { + if c == 'A' { + tampered[i] = 'B' + } else { + tampered[i] = 'A' + } + changed = true + break + } + } + if !changed { + t.Fatal("testaufbau fehlerhaft: nichts zum manipulieren gefunden") + } + + if _, err := validator.Parse(string(tampered)); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("erwartet ErrInvalidSignature, habe %v", err) + } +} + +func TestParse_RejectsWrongKeyPair(t *testing.T) { + _, priv := testKeyPair(t) + otherPub, _ := testKeyPair(t) + + issuer := NewIssuer(priv) + validator := NewValidator(otherPub) // falscher oeffentlicher Schluessel + + key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro"}) + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := validator.Parse(key); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("erwartet ErrInvalidSignature, habe %v", err) + } +} + +func TestParse_RejectsMalformedKey(t *testing.T) { + pub, _ := testKeyPair(t) + validator := NewValidator(pub) + + cases := []string{"", "keine-punkt-trennung", "!!!.!!!"} + for _, c := range cases { + if _, err := validator.Parse(c); !errors.Is(err, ErrMalformedKey) { + t.Fatalf("Parse(%q): erwartet ErrMalformedKey, habe %v", c, err) + } + } +} diff --git a/internal/license/store.go b/internal/license/store.go new file mode 100644 index 0000000..a634738 --- /dev/null +++ b/internal/license/store.go @@ -0,0 +1,88 @@ +package license + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNoLicense = errors.New("license: kein lizenzdatensatz fuer diesen tenant") + ErrLicenseExpired = errors.New("license: lizenz abgelaufen") +) + +// Store persistiert den Lizenzumfang je Tenant in der Control-Plane-Registry +// (siehe internal/tenant.Registry — dieselbe Datenbank, aber ein eigener, +// unabhaengiger Store, um internal/tenant nicht um lizenzfremde Belange zu +// erweitern). +type Store struct { + pool *pgxpool.Pool + validator *Validator +} + +func NewStore(pool *pgxpool.Pool, validator *Validator) *Store { + return &Store{pool: pool, validator: validator} +} + +// Install prueft die Signatur des Lizenzschluessels (Akzeptanzkriterium 1) +// und ersetzt den bisherigen Lizenzdatensatz des Tenants vollstaendig. Ein +// bereits abgelaufener, aber korrekt signierter Schluessel wird trotzdem +// gespeichert — der Ablauf wird erst bei der Nutzung (RequireActive) +// bewertet, nicht beim Einspielen. +func (s *Store) Install(ctx context.Context, tenantID, licenseKey string) (Payload, error) { + payload, err := s.validator.Parse(licenseKey) + if err != nil { + return Payload{}, err + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO tenant_licenses (tenant_id, plan, modules, issued_at, valid_until, raw_key, installed_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) + ON CONFLICT (tenant_id) DO UPDATE SET + plan = $2, modules = $3, issued_at = $4, valid_until = $5, raw_key = $6, installed_at = now() + `, tenantID, payload.Plan, payload.Modules, payload.IssuedAt, payload.ValidUntil, licenseKey) + if err != nil { + return Payload{}, fmt.Errorf("lizenz speichern: %w", err) + } + return payload, nil +} + +func (s *Store) get(ctx context.Context, tenantID string) (Payload, error) { + var p Payload + row := s.pool.QueryRow(ctx, ` + SELECT plan, modules, issued_at, valid_until + FROM tenant_licenses WHERE tenant_id = $1 + `, tenantID) + if err := row.Scan(&p.Plan, &p.Modules, &p.IssuedAt, &p.ValidUntil); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Payload{}, ErrNoLicense + } + return Payload{}, fmt.Errorf("lizenz lesen: %w", err) + } + return p, nil +} + +// Status liefert den persistierten Lizenzumfang unabhaengig vom Ablauf +// (Akzeptanzkriterium 3: Plan, Modul-Liste, Laufzeit abfragbar). +func (s *Store) Status(ctx context.Context, tenantID string) (Payload, error) { + return s.get(ctx, tenantID) +} + +// RequireActive liefert den Lizenzumfang NUR, wenn die Lizenz noch nicht +// abgelaufen ist — sonst ErrLicenseExpired statt eines harten Fehlers/Panics +// (Akzeptanzkriterium 2: definierter eingeschraenkter Zustand). Aufrufende +// Module (LIC-02/03) entscheiden, was "eingeschraenkt" konkret bedeutet. +func (s *Store) RequireActive(ctx context.Context, tenantID string) (Payload, error) { + p, err := s.get(ctx, tenantID) + if err != nil { + return Payload{}, err + } + if time.Now().After(p.ValidUntil) { + return Payload{}, ErrLicenseExpired + } + return p, nil +} diff --git a/internal/license/store_test.go b/internal/license/store_test.go new file mode 100644 index 0000000..dec430b --- /dev/null +++ b/internal/license/store_test.go @@ -0,0 +1,177 @@ +package license + +import ( + "context" + "crypto/ed25519" + "errors" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupStoreTest(t *testing.T) (*Store, *Issuer, string, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tenant_licenses ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), + plan TEXT NOT NULL, + modules TEXT[] NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + valid_until TIMESTAMPTZ NOT NULL, + raw_key TEXT NOT NULL, + installed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + var tenantID string + if err := pool.QueryRow(ctx, ` + INSERT INTO tenants (slug, name, db_name, db_dsn) + VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused') + RETURNING id + `).Scan(&tenantID); err != nil { + t.Fatalf("test-tenant anlegen: %v", err) + } + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("schluesselpaar: %v", err) + } + issuer := NewIssuer(priv) + store := NewStore(pool, NewValidator(pub)) + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID) + _, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID) + pool.Close() + } + return store, issuer, tenantID, cleanup +} + +// Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar. +func TestStore_InstallAndStatus(t *testing.T) { + store, issuer, tenantID, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + payload := Payload{ + TenantSlug: "lic_test_tenant", + Plan: "enterprise", + Modules: []string{"dms", "mail", "archive"}, + IssuedAt: time.Now().Truncate(time.Second), + ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second), + } + key, err := issuer.Issue(payload) + if err != nil { + t.Fatalf("issue: %v", err) + } + + if _, err := store.Install(ctx, tenantID, key); err != nil { + t.Fatalf("install: %v", err) + } + + status, err := store.Status(ctx, tenantID) + if err != nil { + t.Fatalf("status: %v", err) + } + if status.Plan != "enterprise" || len(status.Modules) != 3 { + t.Fatalf("status unerwartet: %+v", status) + } +} + +func TestStore_InstallRejectsInvalidSignature(t *testing.T) { + store, _, tenantID, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + _, otherPriv, _ := ed25519.GenerateKey(nil) + foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel + + key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatalf("issue: %v", err) + } + + if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("erwartet ErrInvalidSignature, habe %v", err) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem +// eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz. +func TestStore_RequireActive_DetectsExpiry(t *testing.T) { + store, issuer, tenantID, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + expired := Payload{ + TenantSlug: "lic_test_tenant", + Plan: "pro", + Modules: []string{"dms"}, + IssuedAt: time.Now().Add(-48 * time.Hour), + ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen + } + key, err := issuer.Issue(expired) + if err != nil { + t.Fatalf("issue: %v", err) + } + + // Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz + // muss funktionieren (Ablauf wird erst bei Nutzung bewertet). + if _, err := store.Install(ctx, tenantID, key); err != nil { + t.Fatalf("install sollte trotz ablauf funktionieren: %v", err) + } + + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r) + } + }() + if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) { + t.Fatalf("erwartet ErrLicenseExpired, habe %v", err) + } + }() + + // Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive). + status, err := store.Status(ctx, tenantID) + if err != nil { + t.Fatalf("status sollte trotz ablauf funktionieren: %v", err) + } + if status.Plan != "pro" { + t.Fatalf("status unerwartet: %+v", status) + } +} + +func TestStore_RequireActive_NoLicense(t *testing.T) { + store, _, tenantID, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) { + t.Fatalf("erwartet ErrNoLicense, habe %v", err) + } +} diff --git a/migrations/0004_tenant_licenses.down.sql b/migrations/0004_tenant_licenses.down.sql new file mode 100644 index 0000000..d9728f1 --- /dev/null +++ b/migrations/0004_tenant_licenses.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tenant_licenses; diff --git a/migrations/0004_tenant_licenses.up.sql b/migrations/0004_tenant_licenses.up.sql new file mode 100644 index 0000000..9872cbf --- /dev/null +++ b/migrations/0004_tenant_licenses.up.sql @@ -0,0 +1,12 @@ +-- Lizenzumfang pro Mandant (LIC-01, siehe core-kanban/tickets/LIC-01.md). +-- Genau ein Lizenzdatensatz pro Tenant (tenant_id PK) — ein neues Einspielen +-- ersetzt den vorherigen Datensatz vollstaendig statt eine Historie zu fuehren. +CREATE TABLE tenant_licenses ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), + plan TEXT NOT NULL, + modules TEXT[] NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + valid_until TIMESTAMPTZ NOT NULL, + raw_key TEXT NOT NULL, + installed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..5bc2bc5 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte +# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle +# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches +# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische +# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +ROLE="nexarch_test" + +export PGPASSWORD="$PASS" + +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" + +dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") +for db in $dbs; do + psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";" +done + +echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..83f26b6 --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost: +# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch. +# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf +# dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +cd "$(dirname "$0")/.." + +NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh + +export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable" + +echo "== go build ==" +go build ./... + +echo "== go vet ==" +go vet ./... + +echo "== go test (-p 1) ==" +go test ./... -p 1 -count=1