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.
203 lines
7.4 KiB
Go
203 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|