Files
nexarch/dms/internal/sharelinkapi/handler_test.go
patrickandClaude Sonnet 5 140751ea48
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
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
DOC-22: externe freigabelinks mit passwort und ablauf
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:04:55 +02:00

289 lines
8.7 KiB
Go

package sharelinkapi
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
)
func newServer(e *testenv.Env, enabled bool, maxAttempts int, lockDuration time.Duration) (*Server, *httptest.Server) {
srv := New(e.Pool, e.Storage, e.Crypto, testenv.TenantSlug, enabled, time.Hour, 24*time.Hour, maxAttempts, lockDuration)
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
ts := httptest.NewServer(mux)
srv.SetPathPrefix(ts.URL)
return srv, ts
}
func createLink(t *testing.T, ts *httptest.Server, docID, callerID, password string, downloadLimit *int) (id, token string) {
t.Helper()
body, _ := json.Marshal(map[string]any{"password": password, "download_limit": downloadLimit})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/documents/"+docID+"/share-links", bytes.NewReader(body))
req.Header.Set("X-User-Id", callerID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("create: status %d, body %s", resp.StatusCode, raw)
}
var dto struct {
ID string `json:"id"`
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&dto); err != nil {
t.Fatal(err)
}
// URL hat die Form "<prefix>/share/<token>".
const marker = "/share/"
i := bytes.LastIndex([]byte(dto.URL), []byte(marker))
if i < 0 {
t.Fatalf("unerwartetes url-format: %s", dto.URL)
}
return dto.ID, dto.URL[i+len(marker):]
}
// Akzeptanzkriterium 1: Downloadinhalt entspricht exakt dem Original.
func TestAccess_ServesOriginalContent(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "rechnung.txt", "text/plain", []byte("original inhalt"))
_, ts := newServer(e, true, 5, time.Minute)
defer ts.Close()
_, token := createLink(t, ts, docID, e.UserID, "", nil)
resp, err := http.Get(ts.URL + "/share/" + token)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "original inhalt" {
t.Errorf("inhalt = %q", got)
}
}
func TestAccess_UnknownToken_Generic404(t *testing.T) {
e := testenv.New(t)
_, ts := newServer(e, true, 5, time.Minute)
defer ts.Close()
resp, err := http.Get(ts.URL + "/share/erratener-token")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, erwartet 404", resp.StatusCode)
}
}
func TestAccess_PasswordFlow(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "geheim.txt", "text/plain", []byte("geschuetzt"))
_, ts := newServer(e, true, 5, time.Minute)
defer ts.Close()
_, token := createLink(t, ts, docID, e.UserID, "geheim123", nil)
getResp, err := http.Get(ts.URL + "/share/" + token)
if err != nil {
t.Fatal(err)
}
getResp.Body.Close()
if getResp.StatusCode != http.StatusUnauthorized {
t.Errorf("ohne passwort: status = %d, erwartet 401", getResp.StatusCode)
}
wrong, _ := json.Marshal(map[string]string{"password": "falsch"})
wrongResp, err := http.Post(ts.URL+"/share/"+token, "application/json", bytes.NewReader(wrong))
if err != nil {
t.Fatal(err)
}
wrongResp.Body.Close()
if wrongResp.StatusCode != http.StatusUnauthorized {
t.Errorf("falsches passwort: status = %d, erwartet 401", wrongResp.StatusCode)
}
right, _ := json.Marshal(map[string]string{"password": "geheim123"})
rightResp, err := http.Post(ts.URL+"/share/"+token, "application/json", bytes.NewReader(right))
if err != nil {
t.Fatal(err)
}
defer rightResp.Body.Close()
if rightResp.StatusCode != http.StatusOK {
t.Errorf("richtiges passwort: status = %d, erwartet 200", rightResp.StatusCode)
}
}
func TestAccess_LocksAfterFailedAttempts(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "geheim.txt", "text/plain", []byte("x"))
_, ts := newServer(e, true, 2, time.Minute)
defer ts.Close()
_, token := createLink(t, ts, docID, e.UserID, "geheim123", nil)
wrong, _ := json.Marshal(map[string]string{"password": "falsch"})
for i := 0; i < 2; i++ {
resp, err := http.Post(ts.URL+"/share/"+token, "application/json", bytes.NewReader(wrong))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
right, _ := json.Marshal(map[string]string{"password": "geheim123"})
resp, err := http.Post(ts.URL+"/share/"+token, "application/json", bytes.NewReader(right))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusLocked {
t.Errorf("status = %d, erwartet 423 (gesperrt, auch mit richtigem passwort)", resp.StatusCode)
}
}
func TestAccess_RevokedLink(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "x.txt", "text/plain", []byte("x"))
_, ts := newServer(e, true, 5, time.Minute)
defer ts.Close()
linkID, token := createLink(t, ts, docID, e.UserID, "", nil)
req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/documents/"+docID+"/share-links/"+linkID, nil)
req.Header.Set("X-User-Id", e.UserID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("widerruf: status = %d", resp.StatusCode)
}
accessResp, err := http.Get(ts.URL + "/share/" + token)
if err != nil {
t.Fatal(err)
}
accessResp.Body.Close()
if accessResp.StatusCode != http.StatusNotFound {
t.Errorf("nach widerruf: status = %d, erwartet 404", accessResp.StatusCode)
}
// Erneuter Widerruf ist idempotent-sicher (kein Leck fremder Existenz).
req2, _ := http.NewRequest(http.MethodDelete, ts.URL+"/documents/"+docID+"/share-links/"+linkID, nil)
req2.Header.Set("X-User-Id", e.UserID)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("erneuter widerruf: status = %d, erwartet 404", resp2.StatusCode)
}
}
func TestAccess_TrashedDocument_NeverServed(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "x.txt", "text/plain", []byte("x"))
_, ts := newServer(e, true, 5, time.Minute)
defer ts.Close()
_, token := createLink(t, ts, docID, e.UserID, "", nil)
if _, err := e.Pool.Exec(context.Background(), `UPDATE documents SET trashed_at = now() WHERE id = $1`, docID); err != nil {
t.Fatal(err)
}
resp, err := http.Get(ts.URL + "/share/" + token)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("papierkorb: status = %d, erwartet 404", resp.StatusCode)
}
}
func TestList_NeverExposesTokenOrPassword(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "x.txt", "text/plain", []byte("x"))
_, ts := newServer(e, true, 5, time.Minute)
defer ts.Close()
_, token := createLink(t, ts, docID, e.UserID, "geheim123", nil)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/documents/"+docID+"/share-links", nil)
req.Header.Set("X-User-Id", e.UserID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if bytes.Contains(raw, []byte(token)) {
t.Errorf("liste enthaelt den token: %s", raw)
}
if bytes.Contains(raw, []byte("geheim123")) {
t.Errorf("liste enthaelt das passwort: %s", raw)
}
var dtos []map[string]any
if err := json.Unmarshal(raw, &dtos); err != nil {
t.Fatal(err)
}
if len(dtos) != 1 || dtos[0]["has_password"] != true {
t.Errorf("unerwartete liste: %s", raw)
}
}
func TestCreate_DisabledForTenant(t *testing.T) {
e := testenv.New(t)
docID, _ := e.UploadDoc(t, "x.txt", "text/plain", []byte("x"))
_, ts := newServer(e, false, 5, time.Minute)
defer ts.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/documents/"+docID+"/share-links", bytes.NewReader([]byte(`{}`)))
req.Header.Set("X-User-Id", e.UserID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status = %d, erwartet 503", resp.StatusCode)
}
}
// Akzeptanzkriterium 3/Pruefung: fremder Mandant. Modell C legt je Mandant
// eine eigene Datenbank an -- ein Token aus Umgebung A existiert in der
// Datenbank von Umgebung B strukturell nicht.
func TestAccess_TokenFromOtherTenantIsUnknown(t *testing.T) {
a := testenv.New(t)
b := testenv.New(t)
docID, _ := a.UploadDoc(t, "x.txt", "text/plain", []byte("x"))
_, tsA := newServer(a, true, 5, time.Minute)
defer tsA.Close()
_, tsB := newServer(b, true, 5, time.Minute)
defer tsB.Close()
_, token := createLink(t, tsA, docID, a.UserID, "", nil)
resp, err := http.Get(tsB.URL + "/share/" + token)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("fremder mandant: status = %d, erwartet 404", resp.StatusCode)
}
}