CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption, Standbild und Vertragslage rein" — bisher wurde nur die Caption verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt. - internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission. Migration 0005 macht asset append-only (Trigger fehlte seit 0001, weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei extraction/finding/evidence_package. - handleCheck liest ein optionales "standbild"-Formularfeld (Bild- Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und legt die Asset-Zeile an. - handleArchive bindet den Asset-Hash (falls vorhanden) in den Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war bereits vorbereitet, wurde aber nie befüllt). - index.html: Formular auf multipart/form-data umgestellt (hx-encoding + enctype), neues optionales Dateifeld. handleCheck bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen" behandelt, nicht wie ein Fehler). - ASSET_DIR neue Konfigurationsvariable (Default "assets", wie DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein postinst-Healing nötig, anders als bei RULES_DIR, dessen Default nicht zum installierten Pfad passt). Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen einen laufenden Server verifiziert (Upload, Hash in DB, Hash im erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
192 lines
7.0 KiB
Go
192 lines
7.0 KiB
Go
package web_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/netcell-it/deklarix/internal/rules"
|
|
"github.com/netcell-it/deklarix/internal/web"
|
|
)
|
|
|
|
// tinyPNG ist das kleinstmögliche gültige PNG (1x1 transparent) — genug,
|
|
// um einen echten Datei-Upload zu simulieren, ohne eine Bilddatei aus
|
|
// dem Repo laden zu müssen.
|
|
var tinyPNG = []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
|
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
|
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
}
|
|
|
|
// postCheckWithImage stellt eine echte multipart/form-data-Anfrage wie
|
|
// der Browser sie schickt (im Gegensatz zu postForm, das urlencoded
|
|
// postet) — checkForm()-Felder plus ein optionales "standbild".
|
|
func postCheckWithImage(t *testing.T, s *web.Server, cookie *http.Cookie, imageBytes []byte, contentType string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
for key, val := range checkForm() {
|
|
if err := mw.WriteField(key, val[0]); err != nil {
|
|
t.Fatalf("WriteField(%s): %v", key, err)
|
|
}
|
|
}
|
|
if imageBytes != nil {
|
|
part, err := mw.CreatePart(map[string][]string{
|
|
"Content-Disposition": {`form-data; name="standbild"; filename="screenshot.png"`},
|
|
"Content-Type": {contentType},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePart: %v", err)
|
|
}
|
|
if _, err := part.Write(imageBytes); err != nil {
|
|
t.Fatalf("Write image bytes: %v", err)
|
|
}
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatalf("multipart Close: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/pruefen", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestCheckWithImageUploadStoresAsset(t *testing.T) {
|
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
}})
|
|
|
|
resp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var subID string
|
|
for id := range fs.submissions {
|
|
subID = id
|
|
}
|
|
if subID == "" {
|
|
t.Fatal("expected a submission to have been created")
|
|
}
|
|
asset, err := fs.GetLatestAssetForSubmission(context.Background(), subID)
|
|
if err != nil {
|
|
t.Fatalf("expected an asset to be stored, got err: %v", err)
|
|
}
|
|
if asset.Kind != "image" || asset.SHA256 == "" {
|
|
t.Errorf("unexpected asset: %+v", asset)
|
|
}
|
|
}
|
|
|
|
func TestCheckWithoutImageStoresNoAsset(t *testing.T) {
|
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
}})
|
|
|
|
resp := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
var subID string
|
|
for id := range fs.submissions {
|
|
subID = id
|
|
}
|
|
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
|
|
t.Fatal("expected no asset when none was uploaded")
|
|
}
|
|
}
|
|
|
|
func TestCheckRejectsNonImageUpload(t *testing.T) {
|
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
}})
|
|
|
|
resp := postCheckWithImage(t, s, cookie, []byte("kein bild, nur text"), "text/plain")
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 for a non-image upload, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if len(fs.submissions) != 0 {
|
|
t.Error("expected no submission to be created when the upload is rejected")
|
|
}
|
|
}
|
|
|
|
func TestCheckRejectsOversizedUpload(t *testing.T) {
|
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
}})
|
|
|
|
tooLarge := bytes.Repeat([]byte{0xff}, 9<<20) // 9 MiB > 8 MiB Limit
|
|
resp := postCheckWithImage(t, s, cookie, tooLarge, "image/png")
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 for an oversized upload, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestArchiveMetadataHashDiffersWhenAssetPresent prüft schwarz-verpackt
|
|
// (ohne PDF-Interna zu kennen — der Dossier-Content wird komprimiert,
|
|
// ein hex-Hash taucht daher nicht als durchsuchbarer String in den
|
|
// PDF-Rohbytes auf, siehe internal/dossier/content_test.go für die
|
|
// Prüfung auf Ebene der PDF-Inhaltsstruktur), dass ein hochgeladenes
|
|
// Standbild tatsächlich in den archivierten Metadaten-Hash einfließt:
|
|
// zwei sonst identische Beiträge, einer mit, einer ohne Bild, müssen
|
|
// unterschiedliche evidence_package.SHA256 ergeben.
|
|
func TestArchiveMetadataHashDiffersWhenAssetPresent(t *testing.T) {
|
|
fakeEx := fakeExtractor{
|
|
facts: rules.Facts{Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone},
|
|
raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`),
|
|
}
|
|
s, fs, cookie := newAuthedTestServer(t, fakeEx)
|
|
|
|
withImageResp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
|
|
if withImageResp.Code != http.StatusOK {
|
|
t.Fatalf("check (mit Bild) status = %d, body: %s", withImageResp.Code, withImageResp.Body.String())
|
|
}
|
|
var withImageSubID string
|
|
for id := range fs.submissions {
|
|
withImageSubID = id
|
|
}
|
|
archiveWithImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withImageSubID}})
|
|
if archiveWithImage.Code != http.StatusOK {
|
|
t.Fatalf("archive (mit Bild) status = %d, body: %s", archiveWithImage.Code, archiveWithImage.Body.String())
|
|
}
|
|
pkgWithImage, err := fs.GetLatestEvidencePackage(context.Background(), withImageSubID)
|
|
if err != nil {
|
|
t.Fatalf("GetLatestEvidencePackage (mit Bild): %v", err)
|
|
}
|
|
|
|
withoutImageResp := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
if withoutImageResp.Code != http.StatusOK {
|
|
t.Fatalf("check (ohne Bild) status = %d, body: %s", withoutImageResp.Code, withoutImageResp.Body.String())
|
|
}
|
|
var withoutImageSubID string
|
|
for id := range fs.submissions {
|
|
if id != withImageSubID {
|
|
withoutImageSubID = id
|
|
}
|
|
}
|
|
archiveWithoutImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withoutImageSubID}})
|
|
if archiveWithoutImage.Code != http.StatusOK {
|
|
t.Fatalf("archive (ohne Bild) status = %d, body: %s", archiveWithoutImage.Code, archiveWithoutImage.Body.String())
|
|
}
|
|
pkgWithoutImage, err := fs.GetLatestEvidencePackage(context.Background(), withoutImageSubID)
|
|
if err != nil {
|
|
t.Fatalf("GetLatestEvidencePackage (ohne Bild): %v", err)
|
|
}
|
|
|
|
if pkgWithImage.SHA256 == pkgWithoutImage.SHA256 {
|
|
t.Fatal("expected different metadata hashes for an archived submission with vs. without an uploaded asset")
|
|
}
|
|
}
|