Files
patrickandClaude Sonnet 5 9012be2da2
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (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
MET-06: metadaten aus e-rechnungen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:42:37 +02:00

197 lines
6.8 KiB
Go

package einvoice
import (
"errors"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus"
)
func referenceFormats(t *testing.T, spec testcorpus.InvoiceSpec) []struct {
name string
data []byte
format Format
} {
t.Helper()
zug, err := testcorpus.GenerateZUGFeRD(spec)
if err != nil {
t.Fatal(err)
}
return []struct {
name string
data []byte
format Format
}{
{"ubl-invoice", testcorpus.GenerateUBLInvoice(spec), FormatUBLInvoice},
{"ubl-creditnote", testcorpus.GenerateUBLCreditNote(spec), FormatUBLCreditNote},
{"cii", testcorpus.GenerateCII(spec), FormatCII},
{"zugferd-pdf", zug, FormatCII},
}
}
// Akzeptanzkriterium 1 und 2 / Pruefung 1: alle Formate werden erkannt, die Kerndaten stimmen.
func TestRead_ReferenceSetMatchesExpectedValues(t *testing.T) {
spec := testcorpus.DefaultInvoice()
for _, c := range referenceFormats(t, spec) {
t.Run(c.name, func(t *testing.T) {
res, err := Read(c.data)
if err != nil {
t.Fatalf("read: %v", err)
}
want := map[string]string{
KeyNumber: spec.Number, KeyIssueDate: spec.IssueDate, KeyDueDate: spec.DueDate,
KeySeller: spec.Seller, KeyBuyer: spec.Buyer, KeyNet: "100.00", KeyTax: "19.00", KeyGross: "119.00",
KeyCurrency: "EUR", KeyIBAN: spec.IBAN, KeyLeitweg: spec.LeitwegID,
KeyFormat: string(c.format), KeyStatus: "valid",
}
got := res.Values()
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, want %q", k, got[k], v)
}
}
if len(res.Findings) != 0 {
t.Errorf("findings bei gueltiger rechnung: %v", res.Findings)
}
if !strings.Contains(res.SearchText(), spec.Number) {
t.Error("suchtext enthaelt die rechnungsnummer nicht")
}
})
}
}
// Akzeptanzkriterium 3 / Pruefung 1: fehlerhafte Varianten liefern die erwartete Fehlerliste, nichts wird geraten.
func TestRead_FaultyVariantsProduceFindings(t *testing.T) {
variants := []struct {
name string
mutate func(*testcorpus.InvoiceSpec)
code string
absent string
}{
{"nummer fehlt", func(s *testcorpus.InvoiceSpec) { s.Number = "" }, "E001", KeyNumber},
{"datum fehlt", func(s *testcorpus.InvoiceSpec) { s.IssueDate = "" }, "E002", KeyIssueDate},
{"datum ungueltig", func(s *testcorpus.InvoiceSpec) { s.IssueDate = "2026-13-45" }, "E007", KeyIssueDate},
{"steller fehlt", func(s *testcorpus.InvoiceSpec) { s.Seller = "" }, "E003", KeySeller},
{"empfaenger fehlt", func(s *testcorpus.InvoiceSpec) { s.Buyer = "" }, "E004", KeyBuyer},
{"waehrung fehlt", func(s *testcorpus.InvoiceSpec) { s.Currency = "" }, "E005", KeyCurrency},
{"summe falsch", func(s *testcorpus.InvoiceSpec) { s.Gross = "120.00" }, "E008", ""},
{"positionen falsch", func(s *testcorpus.InvoiceSpec) { s.LineTotal = "99.00" }, "E009", ""},
{"iban pruefsumme", func(s *testcorpus.InvoiceSpec) { s.IBAN = "DE89370400440532013001" }, "E011", KeyIBAN},
{"betrag keine zahl", func(s *testcorpus.InvoiceSpec) { s.Tax = "abc" }, "E012", KeyTax},
}
for _, v := range variants {
spec := testcorpus.DefaultInvoice()
v.mutate(&spec)
for _, c := range referenceFormats(t, spec) {
t.Run(v.name+"/"+c.name, func(t *testing.T) {
res, err := Read(c.data)
if err != nil {
t.Fatalf("read: %v", err)
}
found := false
for _, f := range res.Findings {
found = found || f.Code == v.code
}
if !found {
t.Errorf("finding %s fehlt: %v", v.code, res.Findings)
}
if res.Status() != "invalid" {
t.Errorf("status = %s, want invalid", res.Status())
}
if v.absent != "" {
if val, ok := res.Values()[v.absent]; ok {
t.Errorf("%s = %q wurde trotz fehlerhafter angabe gesetzt", v.absent, val)
}
}
})
}
}
}
func TestRead_CreditNoteDueDateFromPaymentMeans(t *testing.T) {
spec := testcorpus.DefaultInvoice()
res, err := Read(testcorpus.GenerateUBLCreditNote(spec))
if err != nil {
t.Fatal(err)
}
if res.Invoice.DueDate != spec.DueDate {
t.Errorf("faelligkeit = %q, want %q", res.Invoice.DueDate, spec.DueDate)
}
}
const ublHead = `<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">`
// Pruefung 2: Angriffs- und Fehlereingaben werden abgewiesen, ohne Absturz und ohne Aufloesung von Entitaeten.
func TestRead_AttackInputsAreRejected(t *testing.T) {
billion := `<?xml version="1.0"?><!DOCTYPE lolz [<!ENTITY lol "lol"><!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"><!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;"><!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">]>` + ublHead + `<ID>&lol3;</ID></Invoice>`
xxe := `<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>` + ublHead + `<ID>&xxe;</ID></Invoice>`
oversize := `<?xml version="1.0"?>` + ublHead + `<!--` + strings.Repeat("x", MaxXMLBytes) + `--></Invoice>`
broken := ublHead + `<ID>x</Invoice>`
deep := ublHead + strings.Repeat("<a>", 20000)
noRoot := `<?xml version="1.0"?>`
pdfWithDoctype, err := testcorpus.EmbedXML(testcorpus.GeneratePDF("x", []string{"y"}), "factur-x.xml", []byte(xxe))
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
data []byte
}{
{"entity-expansion", []byte(billion)},
{"externe-entitaet", []byte(xxe)},
{"uebergross", []byte(oversize)},
{"kaputtes-xml", []byte(broken)},
{"tiefe-verschachtelung", []byte(deep)},
{"ohne-wurzel", []byte(noRoot)},
{"doctype-im-pdf-anhang", pdfWithDoctype},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
start := time.Now()
res, err := Read(c.data)
if res != nil || !errors.Is(err, ErrUnreadable) {
t.Fatalf("res=%v err=%v, want ErrUnreadable", res, err)
}
if strings.Contains(err.Error(), "root:") {
t.Error("inhalt einer externen datei im fehlertext")
}
if d := time.Since(start); d > 3*time.Second {
t.Errorf("verarbeitung dauerte %v", d)
}
})
}
}
func TestRead_NonInvoiceInputsAreNoFinding(t *testing.T) {
otherAttachment, err := testcorpus.EmbedXML(testcorpus.GeneratePDF("x", []string{"y"}), "notizen.xml", []byte("<a/>"))
if err != nil {
t.Fatal(err)
}
cases := map[string][]byte{
"fremdes-xml": []byte(`<?xml version="1.0"?><note><to>x</to></note>`),
"normales-pdf": testcorpus.GeneratePDF("Beleg", []string{"Text"}),
"anderer-anhang": otherAttachment,
"binaer": {0x00, 0x01, 0x02},
"leer": {},
"bom-und-invoice": append([]byte("\xef\xbb\xbf"), testcorpus.GenerateUBLInvoice(testcorpus.DefaultInvoice())...),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
res, err := Read(data)
if name == "bom-und-invoice" {
if err != nil || res == nil || res.Invoice.Number == "" {
t.Fatalf("bom: res=%v err=%v", res, err)
}
return
}
if !errors.Is(err, ErrNotEInvoice) {
t.Fatalf("err = %v, want ErrNotEInvoice", err)
}
})
}
}