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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
289 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|