Files
patrickandClaude Sonnet 5 83e45f0b4c
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
FDN-22: Tests fuer Sprachbestimmung und Loeschen der Textkopien
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:32:34 +02:00

146 lines
4.9 KiB
Go

package ocr
import (
"bytes"
"context"
"errors"
"strings"
"testing"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/textcrypt"
)
type otherKEK struct{}
func (otherKEK) TenantKEK(context.Context, string) ([]byte, error) {
return bytes.Repeat([]byte{0x5A}, dmscrypto.KEKSize), nil
}
type rawPage struct {
page int
text string
enc []byte
}
func rawPages(t *testing.T, h *harness, revID string) []rawPage {
t.Helper()
rows, err := h.e.Pool.Query(context.Background(), `SELECT page, text, text_enc FROM revision_ocr_pages WHERE revision_id = $1 ORDER BY page`, revID)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []rawPage
for rows.Next() {
var r rawPage
if err := rows.Scan(&r.page, &r.text, &r.enc); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
return out
}
func wrappedKey(t *testing.T, h *harness, revID string) []byte {
t.Helper()
var k []byte
if err := h.e.Pool.QueryRow(context.Background(), `SELECT text_wrapped_dek FROM revision_ocr WHERE revision_id = $1`, revID).Scan(&k); err != nil {
t.Fatal(err)
}
return k
}
// FDN-22 AK1 und Pruefung 1: erkannter Text liegt nur verschluesselt in der Datenbank, die Auslieferung entschluesselt im Speicher.
func TestRecognisedTextIsStoredEncrypted(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
eng := DefaultEngine()
lines := englishPage
img := renderReference(t, eng, lines, 200)
docID, revID, err := h.upload(t, "scan.png", "image/png", pngBytes(t, img), false)
if err != nil {
t.Fatal(err)
}
if st := h.drain(t, revID, h.processor("w1", nil)); st.Status != StatusReady {
t.Fatalf("status %s (%v)", st.Status, st.Error)
}
rows := rawPages(t, h, revID)
if len(rows) != 1 || rows[0].text != "" || len(rows[0].enc) == 0 {
t.Fatalf("rohzeilen: %+v", rows)
}
if len(wrappedKey(t, h, revID)) == 0 {
t.Fatal("kein verpackter textschluessel abgelegt")
}
word := "musterhandel"
if strings.Contains(strings.ToLower(string(rows[0].enc)), word) {
t.Fatalf("klartextwort %q im chiffretext", word)
}
pages, err := Pages(ctx, h.e.Pool, h.e.Crypto, testenv.TenantSlug, revID)
if err != nil || len(pages) != 1 || !strings.Contains(strings.ToLower(pages[0].Text), word) {
t.Fatalf("entschluesselte seite: %+v %v", pages, err)
}
text, err := (TextStore{Pool: h.e.Pool, Crypto: h.e.Crypto, TenantSlug: testenv.TenantSlug}).TextOf(ctx, docID)
if err != nil || !strings.Contains(strings.ToLower(text), word) {
t.Fatalf("textstore: %q %v", text, err)
}
if l, ok, err := RevisionLanguage(ctx, h.e.Pool, revID); err != nil || !ok || l.Code != "en" {
t.Errorf("sprache aus dem verschluesselten text nicht bestimmt: %+v %v %v", l, ok, err)
}
}
// AK2: fremder KEK und fehlender Dienst liefern keinen Klartext.
func TestRecognisedTextFailsClosedWithoutKey(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
img := renderReference(t, DefaultEngine(), referencePages[0], 200)
docID, revID, err := h.upload(t, "scan.png", "image/png", pngBytes(t, img), false)
if err != nil {
t.Fatal(err)
}
h.drain(t, revID, h.processor("w1", nil))
foreign := dmscrypto.NewService(otherKEK{})
if _, err := Pages(ctx, h.e.Pool, foreign, testenv.TenantSlug, revID); !errors.Is(err, textcrypt.ErrDecryptText) {
t.Errorf("fremder kek: %v", err)
}
if _, err := Pages(ctx, h.e.Pool, nil, testenv.TenantSlug, revID); !errors.Is(err, textcrypt.ErrNoCrypto) {
t.Errorf("ohne dienst: %v", err)
}
if text, err := (TextStore{Pool: h.e.Pool}).TextOf(ctx, docID); err == nil || text != "" {
t.Errorf("textstore ohne dienst: %q %v", text, err)
}
}
// Ein bewusstes Duplikat uebernimmt den Text und verschluesselt ihn neu (eigener Schluessel, Bindung an die neue Revision).
func TestReusedTextIsReEncryptedForNewRevision(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
img := renderReference(t, DefaultEngine(), referencePages[0], 200)
data := pngBytes(t, img)
_, rev1, err := h.upload(t, "a.png", "image/png", data, false)
if err != nil {
t.Fatal(err)
}
h.drain(t, rev1, h.processor("w1", nil))
_, rev2, err := h.upload(t, "b.png", "image/png", data, true)
if err != nil {
t.Fatal(err)
}
if st := h.drain(t, rev2, h.processor("w1", nil)); st.Status != StatusReady {
t.Fatalf("status %s", st.Status)
}
if bytes.Equal(wrappedKey(t, h, rev1), wrappedKey(t, h, rev2)) {
t.Fatal("beide revisionen teilen sich den textschluessel")
}
a, b := rawPages(t, h, rev1), rawPages(t, h, rev2)
if len(a) != 1 || len(b) != 1 || b[0].text != "" || bytes.Equal(a[0].enc, b[0].enc) {
t.Fatalf("roh: %+v / %+v", a, b)
}
p1, _ := Pages(ctx, h.e.Pool, h.e.Crypto, testenv.TenantSlug, rev1)
p2, err := Pages(ctx, h.e.Pool, h.e.Crypto, testenv.TenantSlug, rev2)
if err != nil || len(p2) != 1 || p2[0].Text != p1[0].Text || p2[0].Source != SourceReused {
t.Fatalf("uebernommener text: %+v %v", p2, err)
}
}