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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197 lines
6.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|