feat: Standbild-Upload bei der Pre-Publish-Prüfung
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).
This commit is contained in:
191
internal/web/asset_handlers_test.go
Normal file
191
internal/web/asset_handlers_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user