From 1572ec4faf1ffa798048e8601a1c3b49489f2912 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 14:29:52 +0200 Subject: [PATCH 1/4] INT-08: watch-folder-import (consume-ordner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Paket internal/watchfolder: pollt einen konfigurierten Ordner (Groessenstabilitaet ueber zwei stat()-Aufrufe als Settle-Erkennung) und uebergibt jede gefundene Datei unveraendert an internal/upload.Service (StartUpload/UploadChunk/Complete) — derselbe Hash-/Verschluesselungs-/ Speicherpfad wie ein API-Upload (DOC-01), keine eigene Pipeline. Erfolg entfernt die Quelldatei, ein Fehler verschiebt sie nach error/ samt Begleitdatei mit der Fehlermeldung und blockiert die uebrigen Dateien des Laufs nicht. cmd/app/main.go: optionale Hintergrund-Aktivierung ueber NEXARCH_DMS_APP_WATCHFOLDER_DIR (+ _SYSTEM_USER_ID, _STAGING_DIR), analog zum bestehenden RET-05-Muster in setupRetention. Dedup-Gate (DOC-02), OCR (SRC-05) und Suchindex (SRC-01/SRC-02) sind auf diesem Branch nicht mergebar/erreichbar — siehe dms/docs/INT-08-PRUEFPROTOKOLL.md fuer die Einordnung. --- dms/cmd/app/main.go | 89 +++++++++ dms/internal/watchfolder/watcher.go | 200 +++++++++++++++++++ dms/internal/watchfolder/watcher_test.go | 241 +++++++++++++++++++++++ 3 files changed, 530 insertions(+) create mode 100644 dms/internal/watchfolder/watcher.go create mode 100644 dms/internal/watchfolder/watcher_test.go diff --git a/dms/cmd/app/main.go b/dms/cmd/app/main.go index 1d524648..a01f375b 100644 --- a/dms/cmd/app/main.go +++ b/dms/cmd/app/main.go @@ -8,15 +8,20 @@ import ( "log" "net/http" "os" + "strconv" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentiondestroy" "gitea.perlbach24.de/scripte/nexarch/dms/internal/shared" "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/watchfolder" ) func main() { @@ -40,6 +45,14 @@ func main() { log.Print("dms-app: NEXARCH_DMS_APP_TENANT_DSN nicht gesetzt, RET-05-Anbindung (DOC-16) deaktiviert") } + watchCtx, stopWatch := context.WithCancel(context.Background()) + defer stopWatch() + if dir := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_DIR"); dir != "" { + setupWatchFolder(watchCtx, dir) + } else { + log.Print("dms-app: NEXARCH_DMS_APP_WATCHFOLDER_DIR nicht gesetzt, INT-08-Consume-Ordner deaktiviert") + } + log.Printf("dms-app hoert auf %s (version %s)", addr, shared.Version) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) @@ -92,3 +105,79 @@ func setupRetention(mux *http.ServeMux, dsn string) { log.Print("dms-app: bei archive RET-05 registriert") } } + +// setupWatchFolder startet INT-08s Consume-Ordner-Beobachtung im +// Hintergrund und mündet in dieselbe upload.Service-Pipeline wie DOC-01 +// (Hash/Verschlüsselung/Dokument+Revision) — es wird kein eigener Pfad +// dupliziert. Läuft nur, wenn Tenant-DSN, Staging-Verzeichnis und ein +// bereits angelegter System-Benutzer (created_by) konfiguriert sind. +func setupWatchFolder(ctx context.Context, dir string) { + dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN") + systemUserID := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_SYSTEM_USER_ID") + stagingDir := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_STAGING_DIR") + if dsn == "" || systemUserID == "" || stagingDir == "" { + log.Print("dms-app: INT-08-Consume-Ordner uebersprungen (TENANT_DSN/WATCHFOLDER_SYSTEM_USER_ID/WATCHFOLDER_STAGING_DIR unvollstaendig)") + return + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("dms-app: watchfolder-datenbankverbindung: %v", err) + } + + storageDir := os.Getenv("NEXARCH_DMS_APP_STORAGE_DIR") + signingSecret := os.Getenv("NEXARCH_DMS_APP_STORAGE_SIGNING_SECRET") + publicBaseURL := os.Getenv("NEXARCH_DMS_APP_STORAGE_PUBLIC_BASE_URL") + driver := storage.NewLocalDriver(storageDir, []byte(signingSecret), publicBaseURL) + usageReporter := storage.NewHTTPUsageReporter( + os.Getenv("NEXARCH_DMS_APP_USAGE_ENDPOINT_URL"), + os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_ID"), + os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_SECRET"), + nil, + ) + tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG") + storageSvc := storage.NewService(driver, usageReporter, tenantSlug) + + kekProvider := dmscrypto.NewHTTPKEKProvider( + os.Getenv("NEXARCH_DMS_APP_KEK_ENDPOINT_URL"), + os.Getenv("NEXARCH_DMS_APP_KEK_CLIENT_ID"), + os.Getenv("NEXARCH_DMS_APP_KEK_CLIENT_SECRET"), + nil, + ) + cryptoSvc := dmscrypto.NewService(kekProvider) + + maxSize := int64(0) + if raw := os.Getenv("NEXARCH_DMS_APP_UPLOAD_MAX_SIZE_BYTES"); raw != "" { + if v, err := strconv.ParseInt(raw, 10, 64); err == nil { + maxSize = v + } + } + var allowedMimeTypes []string + if raw := os.Getenv("NEXARCH_DMS_APP_UPLOAD_ALLOWED_MIME_TYPES"); raw != "" { + allowedMimeTypes = strings.Split(raw, ",") + } + validator := upload.NewValidator(maxSize, allowedMimeTypes) + + sessions := upload.NewSessionStore(pool) + staging := upload.NewStaging(stagingDir) + svc := upload.NewService(pool, sessions, staging, storageSvc, cryptoSvc, validator, tenantSlug) + + pollInterval := 5 * time.Second + if raw := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_POLL_INTERVAL_SECONDS"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + pollInterval = time.Duration(v) * time.Second + } + } + + watcher := watchfolder.New(svc, watchfolder.Config{ + Dir: dir, + PollInterval: pollInterval, + CreatedBy: systemUserID, + }) + go func() { + log.Printf("dms-app: INT-08-Consume-Ordner beobachtet %q", dir) + if err := watcher.Run(ctx); err != nil { + log.Printf("dms-app: watchfolder beendet: %v", err) + } + }() +} diff --git a/dms/internal/watchfolder/watcher.go b/dms/internal/watchfolder/watcher.go new file mode 100644 index 00000000..a19a5281 --- /dev/null +++ b/dms/internal/watchfolder/watcher.go @@ -0,0 +1,200 @@ +// Package watchfolder implementiert INT-08: ein Dateisystem-Ordner, der +// periodisch auf neue Dateien geprueft wird und diese ohne API-Aufruf ueber +// denselben Pipeline-Weg wie internal/upload (DOC-01) einliefert — Hash, +// Verschluesselung und Dokument/Revision-Anlage laufen unveraendert ueber +// upload.Service, dieses Paket dupliziert die Pipeline nicht. +package watchfolder + +import ( + "context" + "fmt" + "io" + "log" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" +) + +const errorSubdir = "error" + +// Config steuert Ort, Takt und Zuordnung des Consume-Ordners. Kein Feld +// enthaelt Zugangsdaten — der Ordnerpfad selbst ist Konfiguration, kein +// Geheimnis. +type Config struct { + Dir string + PollInterval time.Duration + SettleDelay time.Duration + FolderID *string + CreatedBy string +} + +// Watcher pollt Config.Dir und uebergibt jede settled Datei an svc. +type Watcher struct { + svc *upload.Service + cfg Config +} + +func New(svc *upload.Service, cfg Config) *Watcher { + if cfg.PollInterval <= 0 { + cfg.PollInterval = 5 * time.Second + } + if cfg.SettleDelay <= 0 { + cfg.SettleDelay = 2 * time.Second + } + return &Watcher{svc: svc, cfg: cfg} +} + +func (w *Watcher) errorDir() string { + return filepath.Join(w.cfg.Dir, errorSubdir) +} + +// Run blockiert bis ctx endet und scannt Config.Dir im Takt von +// PollInterval (Akzeptanzkriterium 1: automatische Erkennung ohne +// manuellen Trigger). +func (w *Watcher) Run(ctx context.Context) error { + if err := os.MkdirAll(w.cfg.Dir, 0o755); err != nil { + return fmt.Errorf("watchfolder: ordner anlegen: %w", err) + } + if err := os.MkdirAll(w.errorDir(), 0o755); err != nil { + return fmt.Errorf("watchfolder: fehler-unterordner anlegen: %w", err) + } + + ticker := time.NewTicker(w.cfg.PollInterval) + defer ticker.Stop() + + w.ScanOnce(ctx) + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + w.ScanOnce(ctx) + } + } +} + +// ScanOnce prueft Config.Dir einmalig auf importbereite Dateien — ein +// Fehler bei einer Datei stoppt die uebrigen nicht (Akzeptanzkriterium 3). +func (w *Watcher) ScanOnce(ctx context.Context) { + entries, err := os.ReadDir(w.cfg.Dir) + if err != nil { + log.Printf("watchfolder: ordner lesen: %v", err) + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(w.cfg.Dir, entry.Name()) + + if !w.isSettled(path) { + continue + } + + if err := w.importFile(ctx, path); err != nil { + log.Printf("watchfolder: import von %q fehlgeschlagen, verschiebe nach %q: %v", path, errorSubdir, err) + if moveErr := w.moveToError(path, err); moveErr != nil { + log.Printf("watchfolder: verschieben nach fehler-unterordner fehlgeschlagen fuer %q: %v", path, moveErr) + } + } + } +} + +// isSettled erkennt eine noch laufende Schreiboperation durch zwei +// stat()-Aufrufe im Abstand von SettleDelay — eine unveraenderte Groesse +// gilt als abgeschlossen geschrieben. +func (w *Watcher) isSettled(path string) bool { + first, err := os.Stat(path) + if err != nil { + return false + } + time.Sleep(w.cfg.SettleDelay) + second, err := os.Stat(path) + if err != nil { + return false + } + return first.Size() == second.Size() && first.ModTime().Equal(second.ModTime()) +} + +// importFile fuehrt die Datei durch dieselbe StartUpload/UploadChunk/ +// Complete-Sequenz wie ein API-Upload (Akzeptanzkriterium 2) und entfernt +// die Quelldatei danach. +func (w *Watcher) importFile(ctx context.Context, path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("datei oeffnen: %w", err) + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("dateigroesse ermitteln: %w", err) + } + + mimeType, err := detectMimeType(f, filepath.Base(path)) + if err != nil { + return fmt.Errorf("mime-typ erkennen: %w", err) + } + + sessionID, err := w.svc.StartUpload(ctx, filepath.Base(path), mimeType, info.Size(), w.cfg.FolderID, w.cfg.CreatedBy) + if err != nil { + return fmt.Errorf("upload starten: %w", err) + } + if _, err := w.svc.UploadChunk(ctx, sessionID, 0, f); err != nil { + return fmt.Errorf("inhalt uebertragen: %w", err) + } + if _, _, err := w.svc.Complete(ctx, sessionID); err != nil { + return fmt.Errorf("upload abschliessen: %w", err) + } + + if err := os.Remove(path); err != nil { + return fmt.Errorf("quelldatei nach erfolgreichem import entfernen: %w", err) + } + return nil +} + +// detectMimeType nutzt zuerst die Dateiendung, da sie fuer Bueroformate +// (docx, xlsx, ...) zuverlaessiger ist als eine Signaturpruefung der ersten +// Bytes, und faellt sonst auf http.DetectContentType zurueck. +func detectMimeType(f *os.File, filename string) (string, error) { + if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" { + return strings.SplitN(byExt, ";", 2)[0], nil + } + + buf := make([]byte, 512) + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return "", err + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return "", err + } + return http.DetectContentType(buf[:n]), nil +} + +// moveToError verschiebt eine fehlerhafte/nicht lesbare Datei unveraendert +// in den Fehler-Unterordner (Akzeptanzkriterium 3) und legt eine +// Begleitdatei mit der Fehlermeldung fuer die manuelle Pruefung ab. +func (w *Watcher) moveToError(path string, importErr error) error { + if err := os.MkdirAll(w.errorDir(), 0o755); err != nil { + return err + } + + name := filepath.Base(path) + dest := filepath.Join(w.errorDir(), name) + if _, err := os.Stat(dest); err == nil { + dest = filepath.Join(w.errorDir(), fmt.Sprintf("%d-%s", time.Now().UnixNano(), name)) + } + + if err := os.Rename(path, dest); err != nil { + return err + } + _ = os.WriteFile(dest+".error.txt", []byte(importErr.Error()+"\n"), 0o644) + return nil +} diff --git a/dms/internal/watchfolder/watcher_test.go b/dms/internal/watchfolder/watcher_test.go new file mode 100644 index 00000000..7c64528f --- /dev/null +++ b/dms/internal/watchfolder/watcher_test.go @@ -0,0 +1,241 @@ +package watchfolder + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" +) + +type fakeKEKProvider struct{ kek []byte } + +func (f *fakeKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) { + return f.kek, nil +} + +type noopUsageReporter struct{} + +func (noopUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error { + return nil +} + +// setupTest baut dieselbe Tabellen-Fixture wie internal/upload's eigene +// Tests auf, da watchfolder ausschliesslich upload.Service aufruft und +// keine eigene Datenzugriffsschicht hat. +func setupTest(t *testing.T) (svc *upload.Service, pool *pgxpool.Pool, userID string) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS 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() + ); + CREATE TABLE IF NOT EXISTS document_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL UNIQUE, retention_class TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS folders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE, + name TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), + default_document_type_id UUID REFERENCES document_types(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, + title TEXT NOT NULL, current_revision_id UUID, created_by UUID NOT NULL REFERENCES users(id), + document_type_id UUID REFERENCES document_types(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS file_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + revision_number INT NOT NULL, storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, + size_bytes BIGINT NOT NULL, mime_type TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), wrapped_dek BYTEA, + UNIQUE (document_id, revision_number) + ); + CREATE TABLE IF NOT EXISTS upload_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), filename TEXT NOT NULL, mime_type TEXT NOT NULL, + total_size BIGINT NOT NULL CHECK (total_size > 0), bytes_received BIGINT NOT NULL DEFAULT 0 CHECK (bytes_received >= 0), + folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, created_by UUID NOT NULL REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'uploading' CHECK (status IN ('uploading','completed','aborted')), + document_id UUID REFERENCES documents(id), + scan_status TEXT NOT NULL DEFAULT 'pending' CHECK (scan_status IN ('pending','clean','rejected')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `TRUNCATE upload_sessions, file_revisions, documents, folders, document_types, users CASCADE`) + }) + + if err := pool.QueryRow(ctx, ` + INSERT INTO users (email, name) VALUES ($1, 'Watchfolder-Test-Benutzer') RETURNING id + `, fmt.Sprintf("watchfolder-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil { + t.Fatalf("testbenutzer anlegen: %v", err) + } + + sessions := upload.NewSessionStore(pool) + staging := upload.NewStaging(t.TempDir()) + storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test"), noopUsageReporter{}, "acme") + cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x11}, dmscrypto.KEKSize)}) + validator := upload.NewValidator(10*1024*1024, nil) + + svc = upload.NewService(pool, sessions, staging, storageSvc, cryptoSvc, validator, "acme") + return svc, pool, userID +} + +// TestScanOnce_DetectsAndImportsDroppedFile ist Pflichtpruefung 1 und +// Akzeptanzkriterium 1: eine im Ordner abgelegte Datei wird ohne manuellen +// Trigger als Dokument angelegt. +func TestScanOnce_DetectsAndImportsDroppedFile(t *testing.T) { + svc, pool, userID := setupTest(t) + ctx := context.Background() + dir := t.TempDir() + + content := []byte("INT-08 Testinhalt fuer Consume-Ordner") + if err := os.WriteFile(filepath.Join(dir, "beleg.txt"), content, 0o644); err != nil { + t.Fatalf("testdatei schreiben: %v", err) + } + + w := New(svc, Config{Dir: dir, SettleDelay: 50 * time.Millisecond, CreatedBy: userID}) + if err := os.MkdirAll(w.errorDir(), 0o755); err != nil { + t.Fatalf("fehlerordner anlegen: %v", err) + } + w.ScanOnce(ctx) + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM documents WHERE title = 'beleg.txt'`).Scan(&count); err != nil { + t.Fatalf("dokument zaehlen: %v", err) + } + if count != 1 { + t.Fatalf("dokumentanzahl = %d, want 1", count) + } + + if _, err := os.Stat(filepath.Join(dir, "beleg.txt")); !os.IsNotExist(err) { + t.Fatalf("quelldatei haette nach erfolgreichem import entfernt sein sollen, stat-fehler: %v", err) + } +} + +// TestScanOnce_TraversesHashDedupGateAndEncryption ist Pflichtpruefung 2 und +// Akzeptanzkriterium 2 (fuer den in diesem Repo tatsaechlich erreichbaren +// Teil der Pipeline — siehe INT-08-PRUEFPROTOKOLL.md fuer die Einordnung +// von Dedup-Gate/OCR/Index, die aus separaten, hier nicht gemergten +// Kacheln stammen). +func TestScanOnce_TraversesHashDedupGateAndEncryption(t *testing.T) { + svc, pool, userID := setupTest(t) + ctx := context.Background() + dir := t.TempDir() + + content := []byte("INT-08 Pipeline-Nachweis: Hash und Verschluesselung") + sum := sha256.Sum256(content) + wantChecksum := hex.EncodeToString(sum[:]) + if err := os.WriteFile(filepath.Join(dir, "nachweis.txt"), content, 0o644); err != nil { + t.Fatalf("testdatei schreiben: %v", err) + } + + w := New(svc, Config{Dir: dir, SettleDelay: 50 * time.Millisecond, CreatedBy: userID}) + if err := os.MkdirAll(w.errorDir(), 0o755); err != nil { + t.Fatalf("fehlerordner anlegen: %v", err) + } + w.ScanOnce(ctx) + + var checksum string + var wrappedDEK []byte + var storageKey string + if err := pool.QueryRow(ctx, ` + SELECT checksum_sha256, wrapped_dek, storage_key FROM file_revisions + JOIN documents ON documents.current_revision_id = file_revisions.id + WHERE documents.title = 'nachweis.txt' + `).Scan(&checksum, &wrappedDEK, &storageKey); err != nil { + t.Fatalf("revision lesen: %v", err) + } + if checksum != wantChecksum { + t.Fatalf("checksum_sha256 = %q, want %q (Hash-Stufe nicht durchlaufen)", checksum, wantChecksum) + } + if len(wrappedDEK) == 0 { + t.Fatal("erwartet nicht-leeren wrapped_dek (Verschluesselungs-Stufe nicht durchlaufen)") + } + if storageKey == "" { + t.Fatal("erwartet gesetzten storage_key (Storage-Stufe nicht durchlaufen)") + } +} + +// TestScanOnce_UnreadableFileMovedToErrorSubdirWithoutBlockingOthers ist +// Pflichtpruefung 3 und Akzeptanzkriterium 3: eine absichtlich verworfene +// Datei (hier: unzulaessiger MIME-Typ als Stellvertreter fuer "fehlerhaft/ +// nicht lesbar", da die Validierung an derselben Stelle greift wie bei +// einer defekten Datei) landet im Fehler-Unterordner, eine parallel +// abgelegte gueltige Datei wird trotzdem importiert. +func TestScanOnce_UnreadableFileMovedToErrorSubdirWithoutBlockingOthers(t *testing.T) { + svc, pool, userID := setupTest(t) + ctx := context.Background() + dir := t.TempDir() + + // eigener Validator mit Positivliste, damit eine Datei zuverlaessig + // als "fehlerhaft" abgelehnt wird, unabhaengig vom erkannten MIME-Typ. + restricted := upload.NewValidator(10*1024*1024, []string{"text/plain; charset=utf-8"}) + svcRestricted := upload.NewService(pool, upload.NewSessionStore(pool), upload.NewStaging(t.TempDir()), + newStorageSvc(t), newCryptoSvc(), restricted, "acme") + + if err := os.WriteFile(filepath.Join(dir, "defekt.bin"), []byte{0x00, 0x01, 0x02, 0x03}, 0o644); err != nil { + t.Fatalf("defekte testdatei schreiben: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "gueltig.txt"), []byte("gueltiger Inhalt"), 0o644); err != nil { + t.Fatalf("gueltige testdatei schreiben: %v", err) + } + + w := New(svcRestricted, Config{Dir: dir, SettleDelay: 50 * time.Millisecond, CreatedBy: userID}) + if err := os.MkdirAll(w.errorDir(), 0o755); err != nil { + t.Fatalf("fehlerordner anlegen: %v", err) + } + w.ScanOnce(ctx) + + if _, err := os.Stat(filepath.Join(w.errorDir(), "defekt.bin")); err != nil { + t.Fatalf("erwartet defekt.bin im fehler-unterordner: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "defekt.bin")); !os.IsNotExist(err) { + t.Fatal("defekt.bin haette aus dem hauptordner verschoben sein sollen") + } + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM documents WHERE title = 'gueltig.txt'`).Scan(&count); err != nil { + t.Fatalf("dokument zaehlen: %v", err) + } + if count != 1 { + t.Fatalf("dokumentanzahl fuer gueltig.txt = %d, want 1 (fehlerhafte Datei haette restlichen Ordner nicht blockieren duerfen)", count) + } +} + +func newStorageSvc(t *testing.T) *storage.Service { + t.Helper() + return storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test"), noopUsageReporter{}, "acme") +} + +func newCryptoSvc() *dmscrypto.Service { + return dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x11}, dmscrypto.KEKSize)}) +} From c3687e80e488129a8cd63db99cd6e657bd0340aa Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 14:30:20 +0200 Subject: [PATCH 2/4] INT-08: unbenutzte svc-variable im fehler-test entfernen go vet bemaengelte declared-and-not-used, svc wird in diesem Test nicht gebraucht, nur der eigens erzeugte svcRestricted. --- dms/internal/watchfolder/watcher_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dms/internal/watchfolder/watcher_test.go b/dms/internal/watchfolder/watcher_test.go index 7c64528f..779ab2f6 100644 --- a/dms/internal/watchfolder/watcher_test.go +++ b/dms/internal/watchfolder/watcher_test.go @@ -192,7 +192,7 @@ func TestScanOnce_TraversesHashDedupGateAndEncryption(t *testing.T) { // einer defekten Datei) landet im Fehler-Unterordner, eine parallel // abgelegte gueltige Datei wird trotzdem importiert. func TestScanOnce_UnreadableFileMovedToErrorSubdirWithoutBlockingOthers(t *testing.T) { - svc, pool, userID := setupTest(t) + _, pool, userID := setupTest(t) ctx := context.Background() dir := t.TempDir() From 46037ee5d6b3e8259830fbabbde03d19865931d6 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 14:30:47 +0200 Subject: [PATCH 3/4] INT-08: mime-erkennung des testhosts fuer text/plain beruecksichtigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mime.TypeByExtension(".txt") liefert je nach /etc/mime.types entweder "text/plain" oder "text/plain; charset=utf-8" — beide Varianten in der Positivliste des Fehler-Tests zulassen, statt eine feste Systemumgebung vorauszusetzen. --- dms/internal/watchfolder/watcher_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dms/internal/watchfolder/watcher_test.go b/dms/internal/watchfolder/watcher_test.go index 779ab2f6..8cdaa7ab 100644 --- a/dms/internal/watchfolder/watcher_test.go +++ b/dms/internal/watchfolder/watcher_test.go @@ -198,7 +198,7 @@ func TestScanOnce_UnreadableFileMovedToErrorSubdirWithoutBlockingOthers(t *testi // eigener Validator mit Positivliste, damit eine Datei zuverlaessig // als "fehlerhaft" abgelehnt wird, unabhaengig vom erkannten MIME-Typ. - restricted := upload.NewValidator(10*1024*1024, []string{"text/plain; charset=utf-8"}) + restricted := upload.NewValidator(10*1024*1024, []string{"text/plain", "text/plain; charset=utf-8"}) svcRestricted := upload.NewService(pool, upload.NewSessionStore(pool), upload.NewStaging(t.TempDir()), newStorageSvc(t), newCryptoSvc(), restricted, "acme") From 55e6404b4c3a353bdcc139341dd675dba5ec20f3 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 14:32:16 +0200 Subject: [PATCH 4/4] INT-08: pruefprotokoll Dokumentiert die drei Pflichtpruefungen mit realen Testergebnissen sowie die Reichweiten-Einschraenkung fuer Dedup-Gate/OCR/Index (DOC-02, SRC-01, SRC-02, SRC-05 auf diesem Branch nicht mergebar/erreichbar). --- dms/docs/INT-08-PRUEFPROTOKOLL.md | 92 +++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 dms/docs/INT-08-PRUEFPROTOKOLL.md diff --git a/dms/docs/INT-08-PRUEFPROTOKOLL.md b/dms/docs/INT-08-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..9f734ed1 --- /dev/null +++ b/dms/docs/INT-08-PRUEFPROTOKOLL.md @@ -0,0 +1,92 @@ +# INT-08 – Prüfprotokoll: Watch-Folder-Import (Consume-Ordner) + +Welle 4. Voraussetzung: DOC-01 (Status "Fertig"). + +## Umsetzung + +`internal/watchfolder` ergänzt DOC-01s API-Upload um eine dateisystembasierte +Einlieferung, ohne dessen Pipeline zu duplizieren: + +- `Watcher.Run`/`ScanOnce` pollt ein konfiguriertes Verzeichnis (Standard + 5 s, `PollInterval`). Kein `fsnotify`-Einsatz — `grep -r fsnotify dms/go.sum` + ergab keinen Treffer, Polling ist hier die kleinste Lösung. +- `isSettled` erkennt eine noch laufende Schreiboperation über zwei + `os.Stat`-Aufrufe im Abstand von `SettleDelay` (Standard 2 s): Größe und + Änderungszeit müssen zwischen beiden Aufrufen unverändert sein, sonst wird + die Datei im nächsten Durchlauf erneut geprüft. +- `importFile` ruft ausschließlich `upload.Service.StartUpload` / + `UploadChunk` / `Complete` auf — denselben Pfad wie ein API-Upload (Hash, + Verschlüsselung, transaktionale Dokument-/Revisionsanlage). Kein + paralleler Code für diese Schritte. +- Erfolg: Quelldatei wird entfernt (`os.Remove`). +- Fehler (Validierung, defekte/nicht lesbare Datei): Datei wird + unverändert nach `/error/` verschoben (`os.Rename`, bei + Namenskollision mit Zeitstempel-Präfix), zusätzlich eine + `.error.txt`-Begleitdatei mit der Fehlermeldung für die manuelle + Prüfung. Ein Fehler bei einer Datei bricht `ScanOnce` nicht ab, die + Schleife über die restlichen Ordnereinträge läuft weiter. +- `cmd/app/main.go`: optionale Hintergrund-Aktivierung über + `NEXARCH_DMS_APP_WATCHFOLDER_DIR` (+ `_SYSTEM_USER_ID`, `_STAGING_DIR`), + analog zum bestehenden `setupRetention`-Muster. Der Ordnerpfad ist + Konfiguration, keine Zugangsdaten liegen im Code. + +## Reichweite der Pipeline auf diesem Branch (wichtig für Prüfung 2) + +Geprüft mit `git log --all --oneline | grep -iE 'SRC-01|SRC-02|SRC-05|DOC-02'` +und `git merge-base --is-ancestor HEAD`: + +| Baustein | Commit vorhanden im Repo | Erreichbar von diesem Branch (`--is-ancestor`) | +|---|---|---| +| DOC-02 Dublettenerkennung | ja (`182a8ac`, `304368e`) | **nein** | +| SRC-01 Suchindex-Infrastruktur | ja | **nein** | +| SRC-02 Indexierungs-Worker | ja (`c5bdde0`) | **nein** | +| SRC-05 OCR/Facetten | ja (`db73aab`) | **nein** | + +Diese vier Kacheln sind eigene, noch nicht in die INT-08-Vorgängerkette +gemergte Zweige. `internal/upload/service.go` (Stand dieses Branches, wie +tatsächlich gelesen) enthält **keinen** Aufruf eines Dedup-Gates und +**keine** OCR-/Index-Anbindung — nur Hash → Verschlüsselung → Storage → +Dokument/Revision-Transaktion, optional Thumbnail- und Malware-Scan- +Enqueue. Der Watch-Folder-Import mündet exakt in diese (auf diesem Branch +tatsächlich existierende) Pipeline. Dedup-Gate/OCR/Index wurden daher +**nicht** getestet, weil der Code dafür von hier aus nicht aufrufbar ist — +keine Fabrikation einer Integration mit ungemergtem Code. Sobald DOC-02/ +SRC-01/SRC-02/SRC-05 in die Kette gemergt sind, durchläuft ein +Watch-Folder-Import sie automatisch, da `watchfolder` ausschließlich über +`upload.Service` läuft und diesen nicht dupliziert. + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| 1 | Testdatei im Ordner abgelegt, erscheint automatisch als Dokument | Datei wird ohne manuellen Trigger als `documents`-Zeile sichtbar | **bestanden** — `TestScanOnce_DetectsAndImportsDroppedFile`: Datei per `os.WriteFile` real in ein `t.TempDir()` geschrieben, `ScanOnce` (kein manueller Import-Aufruf) findet sie, Dokument mit passendem Titel existiert danach in der DB, Quelldatei ist aus dem Ordner verschwunden | +| 2 | Pipeline-Schritte (Hash, Dedup-Gate, Verschlüsselung) im Test nachweislich durchlaufen | Hash und Verschlüsselung nachweisbar; Dedup-Gate nur, sofern erreichbar | **eingeschränkt bestanden** — `TestScanOnce_TraversesHashDedupGateAndEncryption`: `checksum_sha256` stimmt exakt mit unabhängig berechneter SHA-256 des Originalinhalts überein (Hash-Stufe), `wrapped_dek` ist befüllt (Verschlüsselungs-Stufe), `storage_key` ist gesetzt (Storage-Stufe). Dedup-Gate **nicht testbar** — DOC-02 auf diesem Branch nicht erreichbar (siehe Tabelle oben), offen bis Merge | +| 3 | Absichtlich beschädigte Datei landet im Fehler-Unterordner, blockiert restlichen Ordner nicht | Fehlerdatei in `error/`, gültige Datei im selben Lauf trotzdem importiert | **bestanden** — `TestScanOnce_UnreadableFileMovedToErrorSubdirWithoutBlockingOthers`: Binärdatei mit unzulässigem/erkanntem MIME-Typ landet real in `error/` (Original-Bytes unverändert, plus `.error.txt`), eine im selben `ScanOnce`-Durchlauf abgelegte gültige Datei wird trotzdem erfolgreich zu einem Dokument | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +cd /root/nexarch-code-int08/dms +go build ./... -> clean +go vet ./... -> clean +go test ./internal/watchfolder/... -v -p 1 -> 3/3 PASS +go test ./internal/upload/... -p 1 -> ok (unveraendert, DOC-01 weiterhin gruen) +``` + +`go test ./... -p 1` zeigt zusätzlich Fehlschläge in `metadatafield`, +`migrate`, `thumbnail`, `trash`, `trashapi`, `versioning`, `versioningapi` — +diese sind auf denselben, parallel von anderen Agenten genutzten +`TEST_TENANT_DSN` zurückzuführen (Schema-/Datenreste aus gleichzeitigen +Testläufen anderer Kacheln, z. B. `column "path" does not exist`, +`relation "metadata_field_versions" already exists`) und bestehen +unabhängig von dieser Änderung — außerhalb des Umfangs dieser Kachel, nicht +durch `internal/watchfolder` oder die `cmd/app`-Änderung verursacht. + +## Gesamtergebnis + +**Bestanden für den auf diesem Branch tatsächlich erreichbaren +Pipeline-Umfang.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real durchgeführt. Dedup-Gate/OCR/Index sind wie im +Ticket vorgesehen aus anderen, hier nicht gemergten Kacheln ausgeklammert +und bleiben offen bis zu deren Merge — `watchfolder` selbst braucht dafür +keine Änderung, da es ausschließlich `upload.Service` aufruft.