Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3376cf99dc | ||
|
|
b12d53f469 |
+1
-10
@@ -8,7 +8,6 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -35,20 +34,12 @@ func main() {
|
||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||
tenantHandler := tenant.NewHandler(provisioner)
|
||||
|
||||
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
||||
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
||||
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
||||
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
||||
superadmins := user.NewSuperadminStore(registryPool)
|
||||
userHandler := user.NewHandler(nil, superadmins)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
||||
|
||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
|
||||
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
|
||||
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
|
||||
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
|
||||
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
|
||||
// Schreibpfad (Akzeptanzkriterium 3).
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
|
||||
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
|
||||
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
|
||||
const SystemTenant = "system"
|
||||
|
||||
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
|
||||
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
|
||||
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
|
||||
|
||||
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
|
||||
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
|
||||
type Event struct {
|
||||
TenantSlug string
|
||||
Actor string
|
||||
Action string
|
||||
Target string
|
||||
Metadata map[string]any
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
|
||||
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
|
||||
// direkt in audit_events schreibt und dabei die Validierung umgeht
|
||||
// (Akzeptanzkriterium 3).
|
||||
type Log struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewLog(pool *pgxpool.Pool) *Log {
|
||||
return &Log{pool: pool}
|
||||
}
|
||||
|
||||
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
|
||||
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
|
||||
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
|
||||
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
|
||||
func (l *Log) Record(ctx context.Context, e Event) error {
|
||||
if e.TenantSlug == "" {
|
||||
return ErrMissingTenant
|
||||
}
|
||||
if e.Actor == "" {
|
||||
return ErrMissingActor
|
||||
}
|
||||
if e.Action == "" {
|
||||
return ErrMissingAction
|
||||
}
|
||||
if e.Metadata == nil {
|
||||
e.Metadata = map[string]any{}
|
||||
}
|
||||
metadataJSON, err := json.Marshal(e.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("metadaten serialisieren: %w", err)
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
e.OccurredAt = time.Now()
|
||||
}
|
||||
|
||||
_, err = l.pool.Exec(ctx, `
|
||||
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit-ereignis schreiben: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
|
||||
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
|
||||
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
|
||||
var n int
|
||||
if err := l.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
|
||||
`, tenantSlug).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, 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 audit_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
||||
action TEXT NOT NULL CHECK (action <> ''),
|
||||
target TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
)`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
|
||||
pool.Close()
|
||||
}
|
||||
return NewLog(pool), pool, cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
|
||||
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
|
||||
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
||||
log, pool, cleanup := setupAuditTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
err := log.Record(ctx, Event{
|
||||
TenantSlug: "test_acme",
|
||||
Actor: "alice@example.com",
|
||||
Action: "iam.login_failed",
|
||||
Target: "user:alice@example.com",
|
||||
Metadata: map[string]any{"reason": "falsches passwort"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
count, err := log.CountByTenant(ctx, "test_acme")
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
|
||||
}
|
||||
|
||||
var actor, action, target string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
|
||||
`).Scan(&actor, &action, &target); err != nil {
|
||||
t.Fatalf("eintrag lesen: %v", err)
|
||||
}
|
||||
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
|
||||
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
|
||||
// bereits vom zentralen Schreibpfad abgewiesen.
|
||||
func TestRecord_RejectsMissingTenant(t *testing.T) {
|
||||
log, _, cleanup := setupAuditTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
|
||||
if !errors.Is(err, ErrMissingTenant) {
|
||||
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
|
||||
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
|
||||
// Schutz haengt nicht allein von der Go-Validierung ab.
|
||||
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
|
||||
_, pool, cleanup := setupAuditTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO audit_events (tenant_slug, actor, action, target)
|
||||
VALUES ('', 'alice', 'irgendwas', 'ziel')
|
||||
`)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
|
||||
log, _, cleanup := setupAuditTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
|
||||
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
|
||||
}
|
||||
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
|
||||
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
|
||||
log, _, cleanup := setupAuditTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
|
||||
t.Fatalf("record mit SystemTenant: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package audit
|
||||
|
||||
import "context"
|
||||
|
||||
// AuditLogObjectType ist der Objekttyp-Bezeichner, unter dem Audit-Log-
|
||||
// Eintraege bei der Archive-Retention-Engine registriert werden
|
||||
// (Akzeptanzkriterium 1).
|
||||
const AuditLogObjectType = "audit_log_entry"
|
||||
|
||||
// DefaultAuditRetentionYears ist die GoBD-Buchungsbeleg-Frist (Akzeptanz-
|
||||
// kriterium 2) — Default, pro Tenant ueberschreibbar sofern rechtlich
|
||||
// zulaessig. Die eigentliche Ueberschreibung/Durchsetzung liegt vollstaendig
|
||||
// bei Archive, siehe AuditRetentionTenantOverridable.
|
||||
const DefaultAuditRetentionYears = 10
|
||||
|
||||
// AuditRetentionTenantOverridable erlaubt Archive, die Default-Frist pro
|
||||
// Tenant zu ueberschreiben — Core trifft dabei keine rechtliche Entscheidung,
|
||||
// sondern erlaubt Archive lediglich, so eine Entscheidung zuzulassen.
|
||||
const AuditRetentionTenantOverridable = true
|
||||
|
||||
// RetentionRegistrar ist der Modul-Adapter-Vertrag aus Archive RET-05, wie
|
||||
// Core ihn konsumiert. Die tatsaechliche Implementierung lebt im
|
||||
// Archive-Modul (RET-01/RET-02/RET-05) und existiert zum Zeitpunkt dieser
|
||||
// Kachel noch nicht als Code — Core kennt nur diese Schnittstelle.
|
||||
//
|
||||
// WICHTIG: Core implementiert absichtlich KEINE eigene Loeschlogik fuer
|
||||
// Audit-Eintraege (Akzeptanzkriterium 3). Dieses Paket enthaelt keinen
|
||||
// Delete-Codepfad fuer audit_events ausser dem durch AUD-02 technisch
|
||||
// unterbundenen — die tatsaechliche Loeschung/Aufbewahrungssperre erfolgt
|
||||
// ausschliesslich innerhalb von Archive, ausserhalb dieses Prozesses.
|
||||
type RetentionRegistrar interface {
|
||||
RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error
|
||||
}
|
||||
|
||||
// RegisterWithArchive meldet den Audit-Log-Objekttyp bei der Archive-
|
||||
// Retention-Engine an. Dies ist die EINZIGE Beruehrung dieses Pakets mit
|
||||
// Retention ueberhaupt — kein zweites, Core-eigenes Retention-System
|
||||
// (siehe "Bekannte Fehler vermeiden" im AUD-05-Ticket).
|
||||
func RegisterWithArchive(ctx context.Context, registrar RetentionRegistrar) error {
|
||||
return registrar.RegisterObjectType(ctx, AuditLogObjectType, DefaultAuditRetentionYears, AuditRetentionTenantOverridable)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRegistrar simuliert den RET-05-Modul-Adapter-Vertrag, da Archive
|
||||
// (RET-01/RET-02/RET-05) zum Zeitpunkt dieser Kachel noch nicht als Code
|
||||
// existiert (nur geplant in archive-kanban). Belegt NUR, dass Core mit den
|
||||
// richtigen Parametern registriert — ersetzt KEINE Integrationspruefung
|
||||
// gegen die echte Archive-Engine, siehe Pruefungen-Abschnitt im Commit.
|
||||
type fakeRegistrar struct {
|
||||
objectType string
|
||||
defaultRetentionYears int
|
||||
tenantOverridable bool
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeRegistrar) RegisterObjectType(ctx context.Context, objectType string, defaultRetentionYears int, tenantOverridable bool) error {
|
||||
f.called = true
|
||||
f.objectType = objectType
|
||||
f.defaultRetentionYears = defaultRetentionYears
|
||||
f.tenantOverridable = tenantOverridable
|
||||
return nil
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + 2: Registrierung mit korrektem Objekttyp und
|
||||
// GoBD-Default-Frist von 10 Jahren, tenant-ueberschreibbar.
|
||||
func TestRegisterWithArchive_UsesCorrectObjectTypeAndRetention(t *testing.T) {
|
||||
fake := &fakeRegistrar{}
|
||||
|
||||
if err := RegisterWithArchive(context.Background(), fake); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
if !fake.called {
|
||||
t.Fatal("erwartet aufruf von RegisterObjectType")
|
||||
}
|
||||
if fake.objectType != AuditLogObjectType {
|
||||
t.Fatalf("objectType = %q, want %q", fake.objectType, AuditLogObjectType)
|
||||
}
|
||||
if fake.defaultRetentionYears != 10 {
|
||||
t.Fatalf("defaultRetentionYears = %d, want 10 (GoBD-Frist)", fake.defaultRetentionYears)
|
||||
}
|
||||
if !fake.tenantOverridable {
|
||||
t.Fatal("erwartet tenantOverridable = true")
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
|
||||
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
|
||||
// dieser Kachel und daher hier noch nicht angebunden.
|
||||
type Handler struct {
|
||||
users *TenantUserStore
|
||||
superadmins *SuperadminStore
|
||||
}
|
||||
|
||||
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
|
||||
return &Handler{users: users, superadmins: superadmins}
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.users.Create(r.Context(), req.Email, req.Name)
|
||||
writeUserResult(w, u, err)
|
||||
}
|
||||
|
||||
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
|
||||
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
|
||||
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
|
||||
writeUserResult(w, u, err)
|
||||
}
|
||||
|
||||
func writeUserResult(w http.ResponseWriter, u User, err error) {
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case errors.Is(err, ErrNotFound):
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
default:
|
||||
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(u)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
|
||||
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
|
||||
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
|
||||
// fuer dasselbe Muster.
|
||||
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("admin pool: %v", err)
|
||||
}
|
||||
|
||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||
t.Fatalf("testdatenbank anlegen: %v", err)
|
||||
}
|
||||
|
||||
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect testdatenbank: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
||||
t.Fatalf("schema anwenden: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||
adminPool.Close()
|
||||
})
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
const usersSchema = `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);`
|
||||
|
||||
const superadminsSchema = `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE superadmins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);`
|
||||
|
||||
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
|
||||
func TestTenantUserStore_CRUD(t *testing.T) {
|
||||
pool := setupTestDB(t, "test_iam01_users", usersSchema)
|
||||
store := NewTenantUserStore(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := store.Create(ctx, "alice@example.com", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Status != StatusActive {
|
||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Email != "alice@example.com" {
|
||||
t.Fatalf("get email = %q", got.Email)
|
||||
}
|
||||
|
||||
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
|
||||
t.Fatalf("update ergebnis unerwartet: %+v", updated)
|
||||
}
|
||||
|
||||
list, err := store.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
|
||||
}
|
||||
|
||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("deactivate: %v", err)
|
||||
}
|
||||
if deactivated.Status != StatusInactive {
|
||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
||||
}
|
||||
|
||||
// Negativfall: doppelte E-Mail-Adresse.
|
||||
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
|
||||
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
|
||||
}
|
||||
|
||||
// Negativfall: fehlender Benutzer.
|
||||
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
|
||||
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
|
||||
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
|
||||
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
|
||||
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
|
||||
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
|
||||
store := NewSuperadminStore(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
|
||||
if err != nil {
|
||||
t.Fatalf("create superadmin: %v", err)
|
||||
}
|
||||
if created.Status != StatusActive {
|
||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Email != "root@nexarch.internal" {
|
||||
t.Fatalf("get email = %q", got.Email)
|
||||
}
|
||||
|
||||
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
|
||||
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
|
||||
}
|
||||
|
||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("deactivate: %v", err)
|
||||
}
|
||||
if deactivated.Status != StatusInactive {
|
||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
|
||||
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
|
||||
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
|
||||
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
|
||||
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
|
||||
type SuperadminStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
|
||||
return &SuperadminStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var u User
|
||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
||||
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO superadmins (email, name, status)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, u.Email, u.Name, u.Status)
|
||||
|
||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return User{}, mapWriteErr(err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
|
||||
return scanUser(s.pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, status, created_at, updated_at
|
||||
FROM superadmins WHERE id = $1
|
||||
`, id))
|
||||
}
|
||||
|
||||
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, email, name, status, created_at, updated_at
|
||||
FROM superadmins ORDER BY created_at
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("superadmins auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("superadmin lesen: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
|
||||
return scanUser(s.pool.QueryRow(ctx, `
|
||||
UPDATE superadmins SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, name, status, created_at, updated_at
|
||||
`, id, StatusInactive))
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
|
||||
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
|
||||
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
|
||||
type TenantUserStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
|
||||
return &TenantUserStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var u User
|
||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
||||
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, status)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, u.Email, u.Name, u.Status)
|
||||
|
||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return User{}, mapWriteErr(err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
|
||||
return scanUser(s.pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, status, created_at, updated_at
|
||||
FROM users WHERE id = $1
|
||||
`, id))
|
||||
}
|
||||
|
||||
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, email, name, status, created_at, updated_at
|
||||
FROM users ORDER BY created_at
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("benutzer auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("benutzer lesen: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
|
||||
// Feld unveraendert.
|
||||
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
|
||||
if email != "" {
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET email = COALESCE(NULLIF($2, ''), email),
|
||||
name = COALESCE(NULLIF($3, ''), name),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, name, status, created_at, updated_at
|
||||
`, id, email, name)
|
||||
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return User{}, mapWriteErr(err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
|
||||
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
|
||||
return scanUser(s.pool.QueryRow(ctx, `
|
||||
UPDATE users SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, name, status, created_at, updated_at
|
||||
`, id, StatusInactive))
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var u User
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return User{}, fmt.Errorf("benutzer lesen: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
|
||||
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
|
||||
func mapWriteErr(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrEmailTaken
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("benutzer schreiben: %w", err)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
|
||||
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
|
||||
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
|
||||
// dagegen mandantenuebergreifend in der Registry und sind ueber
|
||||
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
|
||||
// tenant_id-NULL-Sonderfall in User.
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusInactive Status = "inactive"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Status Status
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
|
||||
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
|
||||
var ErrNotFound = errors.New("user: nicht gefunden")
|
||||
|
||||
func ValidateEmail(email string) error {
|
||||
if !emailPattern.MatchString(email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package user
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
email string
|
||||
wantErr bool
|
||||
}{
|
||||
{"a@b.de", false},
|
||||
{"a.b+c@sub.example.com", false},
|
||||
{"", true},
|
||||
{"keine-email", true},
|
||||
{"a@b", true},
|
||||
{"@b.de", true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
err := ValidateEmail(c.email)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS superadmins;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
|
||||
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
|
||||
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
|
||||
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
|
||||
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
|
||||
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
|
||||
CREATE TABLE superadmins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS audit_events;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
|
||||
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
|
||||
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
|
||||
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
|
||||
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
|
||||
-- Wert 'system', niemals NULL oder leeren String.
|
||||
CREATE TABLE audit_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
||||
action TEXT NOT NULL CHECK (action <> ''),
|
||||
target TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
|
||||
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
|
||||
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
|
||||
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
|
||||
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
|
||||
-- nur innerhalb dieser einen Tenant-Datenbank.
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/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;"
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events 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."
|
||||
Executable
+24
@@ -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
|
||||
Reference in New Issue
Block a user