feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.
Neu:
- GET /beitraege: Liste aller Beiträge des angemeldeten Mandanten
(Plattform, Status, höchste Finding-Schwere) via
ListSubmissionsForAccount.
- GET /beitraege/{id}: Detailseite mit Fakten, Findings, Archivieren-
Aktion und Verantwortungsmatrix.
- POST .../beteiligte, .../beteiligte/{pid}/aktualisieren,
.../beteiligte/{pid}/loeschen: echtes CRUD statt nur Ansicht, per
htmx ohne Seiten-Reload.
Mandantentrennung wie beim bestehenden Archiv-Download: fremde
Beiträge und fremde Beteiligte (auch über eine erratene participant_id)
liefern 404, nicht 403 — sonst würde eine 403 die Existenz der Ressource
bei einem anderen Mandanten bestätigen.
Web-Store-Interface um die bereits fertigen Store-Methoden erweitert,
fakeStore in server_test.go entsprechend nachgezogen. Volle Testsuite
inkl. echter Postgres-Tests (./scripts/test.sh) grün.
This commit is contained in:
202
internal/web/archive_handlers_test.go
Normal file
202
internal/web/archive_handlers_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
|
||||
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
|
||||
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
|
||||
// auszuschreiben.
|
||||
func checkAndReturnSubID(t *testing.T, s interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
}, cookie *http.Cookie) string {
|
||||
t.Helper()
|
||||
form := checkForm()
|
||||
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
const marker = `name="submission_id" value="`
|
||||
idx := strings.Index(body, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected a submission_id field in the result, got: %s", body)
|
||||
}
|
||||
rest := body[idx+len(marker):]
|
||||
return rest[:strings.Index(rest, `"`)]
|
||||
}
|
||||
|
||||
func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
subA := checkAndReturnSubID(t, s, cookieA)
|
||||
_ = checkAndReturnSubID(t, s, cookieB)
|
||||
|
||||
resp := getWithCookie(t, s, cookieA, "/beitraege")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "/beitraege/"+subA) {
|
||||
t.Errorf("expected Mandant A's own submission link, got: %s", body)
|
||||
}
|
||||
|
||||
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
|
||||
respB := getWithCookie(t, s, cookieB, "/beitraege")
|
||||
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
|
||||
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionListRequiresSession(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) {
|
||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||
}})
|
||||
subID := checkAndReturnSubID(t, s, cookie)
|
||||
_ = fs
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "WK-004") {
|
||||
t.Errorf("expected the finding to be shown, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "/veroeffentlichen") {
|
||||
t.Errorf("expected an archive option for a checked submission, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionDetailTenantIsolation(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
subA := checkAndReturnSubID(t, s, cookieA)
|
||||
|
||||
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}})
|
||||
subID := checkAndReturnSubID(t, s, cookie)
|
||||
|
||||
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
|
||||
"role": {"creator"}, "name": {"Max Mustermann"},
|
||||
})
|
||||
if addResp.Code != http.StatusOK {
|
||||
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
|
||||
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
|
||||
}
|
||||
|
||||
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
|
||||
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
|
||||
}
|
||||
|
||||
// Beteiligten-ID aus dem Update-Formular extrahieren.
|
||||
body := addResp.Body.String()
|
||||
const marker = "/beteiligte/"
|
||||
idx := strings.Index(body, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected a participant action URL, got: %s", body)
|
||||
}
|
||||
rest := body[idx+len(marker):]
|
||||
pID := rest[:strings.Index(rest, "/")]
|
||||
|
||||
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
|
||||
"vorgegeben": {"on"}, "freigegeben": {"on"},
|
||||
})
|
||||
if updateResp.Code != http.StatusOK {
|
||||
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
|
||||
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusOK {
|
||||
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
|
||||
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
subA := checkAndReturnSubID(t, s, cookieA)
|
||||
|
||||
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
|
||||
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
|
||||
"role": {"creator"}, "name": {"Fremd"},
|
||||
})
|
||||
if addResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
|
||||
}
|
||||
|
||||
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateParticipant: %v", err)
|
||||
}
|
||||
|
||||
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
|
||||
"vorgegeben": {"on"},
|
||||
})
|
||||
if updateResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
|
||||
}
|
||||
|
||||
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
|
||||
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user