diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index d6dc1fb..7aef706 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -1,13 +1,31 @@ package main import ( + "context" "fmt" "log" "net/http" "os" + + "github.com/netcell-it/deklarix/internal/store" ) func main() { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + log.Fatal("DATABASE_URL is required") + } + + if err := store.Migrate(databaseURL); err != nil { + log.Fatalf("migrate: %v", err) + } + + db, err := store.Open(context.Background(), databaseURL) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer db.Close() + port := os.Getenv("PORT") if port == "" { port = "8080" diff --git a/go.mod b/go.mod index baf539e..7597174 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module github.com/netcell-it/deklarix go 1.26.6 + +require ( + github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ce40dc --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0/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= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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= diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..fd26936 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,48 @@ +package store + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + _ "github.com/jackc/pgx/v5/stdlib" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Migrate wendet alle ausstehenden Migrationen aus migrations/ an. +// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix +// weiterhin als einzelnes Binary lauffähig bleibt. +func Migrate(databaseURL string) error { + db, err := sql.Open("pgx", databaseURL) + if err != nil { + return fmt.Errorf("store: open db for migration: %w", err) + } + defer db.Close() + + driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{}) + if err != nil { + return fmt.Errorf("store: migration driver: %w", err) + } + + source, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("store: migration source: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver) + if err != nil { + return fmt.Errorf("store: migrate init: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("store: migrate up: %w", err) + } + return nil +} diff --git a/internal/store/migrations/0001_init.down.sql b/internal/store/migrations/0001_init.down.sql new file mode 100644 index 0000000..dc451ff --- /dev/null +++ b/internal/store/migrations/0001_init.down.sql @@ -0,0 +1,11 @@ +DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package; +DROP TRIGGER IF EXISTS finding_append_only ON finding; +DROP TRIGGER IF EXISTS extraction_append_only ON extraction; +DROP FUNCTION IF EXISTS forbid_update_delete(); + +DROP TABLE IF EXISTS participant; +DROP TABLE IF EXISTS evidence_package; +DROP TABLE IF EXISTS finding; +DROP TABLE IF EXISTS extraction; +DROP TABLE IF EXISTS asset; +DROP TABLE IF EXISTS submission; diff --git a/internal/store/migrations/0001_init.up.sql b/internal/store/migrations/0001_init.up.sql new file mode 100644 index 0000000..ee1190f --- /dev/null +++ b/internal/store/migrations/0001_init.up.sql @@ -0,0 +1,89 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE submission ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')), + post_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')), + caption TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE asset ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submission (id), + kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')), + path TEXT NOT NULL, + sha256 TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten. +CREATE TABLE extraction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submission (id), + payload JSONB NOT NULL, + model_version TEXT NOT NULL, + prompt_version TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile. +-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt +-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das +-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt, +-- siehe Index unten für den Anti-Join. +CREATE TABLE finding ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submission (id), + extraction_id UUID REFERENCES extraction (id), + rule_id TEXT NOT NULL, + rule_version INTEGER NOT NULL, + severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')), + message TEXT NOT NULL, + supersedes UUID REFERENCES finding (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL; + +-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only. +CREATE TABLE evidence_package ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submission (id), + dossier_path TEXT NOT NULL, + sha256 TEXT NOT NULL, + timestamp_token BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE participant ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES submission (id), + role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')), + name TEXT NOT NULL, + vorgegeben BOOLEAN NOT NULL DEFAULT false, + freigegeben BOOLEAN NOT NULL DEFAULT false, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv. +CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER extraction_append_only + BEFORE UPDATE OR DELETE ON extraction + FOR EACH ROW EXECUTE FUNCTION forbid_update_delete(); + +CREATE TRIGGER finding_append_only + BEFORE UPDATE OR DELETE ON finding + FOR EACH ROW EXECUTE FUNCTION forbid_update_delete(); + +CREATE TRIGGER evidence_package_append_only + BEFORE UPDATE OR DELETE ON evidence_package + FOR EACH ROW EXECUTE FUNCTION forbid_update_delete(); diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..927983e --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,31 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store hält den Verbindungspool zur Postgres-Datenbank. +type Store struct { + Pool *pgxpool.Pool +} + +// Open baut den Verbindungspool auf und prüft ihn mit einem Ping. +func Open(ctx context.Context, databaseURL string) (*Store, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("store: open pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("store: ping: %w", err) + } + return &Store{Pool: pool}, nil +} + +// Close gibt den Verbindungspool frei. +func (s *Store) Close() { + s.Pool.Close() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..10cdf0f --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,86 @@ +package store_test + +import ( + "context" + "os" + "testing" + + "github.com/netcell-it/deklarix/internal/store" +) + +// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne +// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen. +func testDatabaseURL(t *testing.T) string { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest") + } + return url +} + +func TestMigrateAndOpen(t *testing.T) { + url := testDatabaseURL(t) + + if err := store.Migrate(url); err != nil { + t.Fatalf("Migrate: %v", err) + } + + s, err := store.Open(context.Background(), url) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + + var tableCount int + err = s.Pool.QueryRow(context.Background(), ` + SELECT count(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = ANY($1) + `, []string{"submission", "asset", "extraction", "finding", "evidence_package", "participant"}).Scan(&tableCount) + if err != nil { + t.Fatalf("query tables: %v", err) + } + if tableCount != 6 { + t.Fatalf("expected 6 tables, got %d", tableCount) + } +} + +func TestFindingIsAppendOnly(t *testing.T) { + url := testDatabaseURL(t) + + if err := store.Migrate(url); err != nil { + t.Fatalf("Migrate: %v", err) + } + + s, err := store.Open(context.Background(), url) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + + ctx := context.Background() + + var submissionID string + err = s.Pool.QueryRow(ctx, ` + INSERT INTO submission (platform, post_type) VALUES ('instagram', 'reel') + RETURNING id + `).Scan(&submissionID) + if err != nil { + t.Fatalf("insert submission: %v", err) + } + + var findingID string + err = s.Pool.QueryRow(ctx, ` + INSERT INTO finding (submission_id, rule_id, rule_version, severity, message) + VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung') + RETURNING id + `, submissionID).Scan(&findingID) + if err != nil { + t.Fatalf("insert finding: %v", err) + } + + _, err = s.Pool.Exec(ctx, `UPDATE finding SET message = 'geändert' WHERE id = $1`, findingID) + if err == nil { + t.Fatal("expected UPDATE on finding to be rejected, but it succeeded") + } +} diff --git a/scripts/test.sh b/scripts/test.sh index 6a44740..8c55e1a 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -15,6 +15,36 @@ fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } cd "$REPO_DIR" +# internal/store hat Integrationstests gegen echtes Postgres (u. a. die +# Append-only-Garantie auf finding/extraction/evidence_package). Ohne +# DATABASE_URL überspringt Go diese Tests still — das darf im +# Release-Pfad (release.sh ruft dieses Skript auf) nicht passieren. +# Deshalb hier immer ein Wegwerf-Postgres hochziehen. +PG_CONTAINER="deklarix-test-pg-$$" +PG_PORT=15432 + +command -v docker >/dev/null 2>&1 || fail "docker wird für die Store-Tests (Postgres) benötigt" + +stop_test_db() { docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 || true; } +trap stop_test_db EXIT + +log "Starte Test-Postgres ($PG_CONTAINER) ..." +docker run -d --name "$PG_CONTAINER" \ + -e POSTGRES_PASSWORD=test -e POSTGRES_DB=deklarix \ + -p "${PG_PORT}:5432" postgres:16-alpine >/dev/null + +ready=0 +for _ in $(seq 1 30); do + if docker exec "$PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done +[[ "$ready" -eq 1 ]] || fail "Test-Postgres wurde nicht rechtzeitig bereit" + +export DATABASE_URL="postgres://postgres:test@localhost:${PG_PORT}/deklarix?sslmode=disable" + log "go vet ..." go vet ./... || fail "go vet fehlgeschlagen"