feat(quick-261009-dkv): Oeffentliche Links nach den Regeln der Nextcloud, Uebersichten und Annehmen, Schnittstelle
- Links anlegen und aendern: Passwort, Ablaufdatum und Bezeichnung nur im JSON-Koerper, Vorpruefung aus den frisch gelesenen Faehigkeiten (Passwort/Ablauf verlangt, Hochladen, mehrere Links), echte Datumspruefung, nur geaenderte Felder beim Aendern - Routen shares/mine, shares/received (angenommen plus offen, 404/405 bei offen = leer) und POST shares/:id/accept am Ende der Klasse - Fehlerzweige fuer Passwort und Ablauf mit Tests; nie Passwort in Antwort oder Fehler - Live-Test e2e-shares.sh: Abschnitte links (Pflicht-Passwort und Pflicht-Ablauf per occ) und received (eingehend, offen, annehmen, verlassen) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+242
-4
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# quick-261009-dkv: Teilen-Schnittstelle gegen die Test-Nextcloud (tessera-nc-test).
|
# quick-261009-dkv: Teilen-Schnittstelle gegen die Test-Nextcloud (tessera-nc-test).
|
||||||
# Aufruf: e2e-shares.sh [people|links|received|version|all] (Vorgabe: all)
|
# Aufruf: e2e-shares.sh [people|links|received|version|all] (Vorgabe: all)
|
||||||
# Aufgabe 1 liefert Aufbau, Aufraeumen und den Abschnitt "people"; links und received
|
# Aufgabe 1 liefert Aufbau, Aufraeumen und den Abschnitt "people"; Aufgabe 2 die Abschnitte
|
||||||
# folgen in Aufgabe 2, version in Aufgabe 3.
|
# "links" (Passwort- und Ablaufregeln der Nextcloud per occ umgeschaltet) und "received"
|
||||||
|
# (eingehende und offene Freigaben); version folgt in Aufgabe 3.
|
||||||
# Setzt einen laufenden Stack (api neu gebaut: docker compose up -d --build api) und
|
# Setzt einen laufenden Stack (api neu gebaut: docker compose up -d --build api) und
|
||||||
# nc-test-setup.sh voraus. Nur Testwerte; liest keine .env-Dateien.
|
# nc-test-setup.sh voraus. Nur Testwerte; liest keine .env-Dateien.
|
||||||
#
|
#
|
||||||
@@ -25,13 +26,20 @@ ANNA="anna:User1-Pass-12345"
|
|||||||
BEN="ben:User3-Pass-12345"
|
BEN="ben:User3-Pass-12345"
|
||||||
EPOCH=$(date +%s)
|
EPOCH=$(date +%s)
|
||||||
FIX="Tessera-Teilen-$EPOCH"
|
FIX="Tessera-Teilen-$EPOCH"
|
||||||
|
BENFIX="Ben-Ordner-$EPOCH"
|
||||||
|
BENBOX="Ben-Briefkasten-$EPOCH"
|
||||||
START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
REPO="$(cd "$HERE/../../../.." && pwd)"
|
||||||
|
|
||||||
# --- Aufraeumen: alles zuruecksetzen, was das Skript angefasst hat ----------------------------
|
# --- Aufraeumen: alles zuruecksetzen, was das Skript angefasst hat ----------------------------
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local rc=$?
|
local rc=$?
|
||||||
set +e
|
set +e
|
||||||
curl -s -o /dev/null -u "$ANNA" -X DELETE "$NC_DAV/files/anna/$FIX" 2>/dev/null
|
curl -s -o /dev/null -u "$ANNA" -X DELETE "$NC_DAV/files/anna/$FIX" 2>/dev/null
|
||||||
|
curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENFIX" 2>/dev/null
|
||||||
|
curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENBOX" 2>/dev/null
|
||||||
|
# Papierkorb leeren, damit abgelehnte oder verlassene Ordner nicht liegen bleiben
|
||||||
|
NC_OCC trashbin:cleanup ben >/dev/null 2>&1
|
||||||
NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1
|
NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1
|
||||||
for key in shareapi_enforce_links_password shareapi_default_expire_date \
|
for key in shareapi_enforce_links_password shareapi_default_expire_date \
|
||||||
shareapi_enforce_expire_date shareapi_expire_after_n_days; do
|
shareapi_enforce_expire_date shareapi_expire_after_n_days; do
|
||||||
@@ -96,6 +104,50 @@ d=json.load(open(sys.argv[1]))["ocs"]["data"]
|
|||||||
print([s["permissions"] for s in d if s.get("file_target","").lstrip("/")==sys.argv[2]][0])' "$E2E_TMP/ben.out" "$1"
|
print([s["permissions"] for s in d if s.get("file_target","").lstrip("/")==sys.argv[2]][0])' "$E2E_TMP/ben.out" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# policy_wait <python-ausdruck> <beschreibung> — wartet bis zu 30 s, bis die Richtlinie den Ausdruck
|
||||||
|
# erfuellt. Die Nextcloud haelt ihre Faehigkeiten nach einer occ-Aenderung kurz noch in einem
|
||||||
|
# Zwischenspeicher (gemessen: Sekunden), Tessera selbst speichert nichts.
|
||||||
|
policy_wait() {
|
||||||
|
local i code ok=0
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
code=$(call GET shares/policy)
|
||||||
|
if [ "$code" = "200" ] && [ "$(jq_py "$1")" = "True" ]; then
|
||||||
|
ok=$((ok + 1))
|
||||||
|
# dreimal hintereinander, falls mehrere Nextcloud-Prozesse verschieden schnell nachziehen
|
||||||
|
[ "$ok" -ge 3 ] && return 0
|
||||||
|
else
|
||||||
|
ok=0
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
e2e_expect True "$(jq_py "$1")" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Direkt bei der Nextcloud: Anzahl der Link-Freigaben (Art 3) eines Pfads in annas Konto
|
||||||
|
anna_link_count() {
|
||||||
|
curl -s -u "$ANNA" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -G \
|
||||||
|
--data-urlencode "path=$1" "$NC_OCS/shares" -o "$E2E_TMP/anna.out"
|
||||||
|
python3 -I -c 'import json,sys
|
||||||
|
d=json.load(open(sys.argv[1]))["ocs"]["data"]
|
||||||
|
print(len([s for s in d if s.get("share_type")==3]))' "$E2E_TMP/anna.out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# nc_put <id> <schluessel> <wert> — dieselbe Aenderung DIREKT bei der Nextcloud (nur zum Messen des
|
||||||
|
# Status, den Tessera sonst hinter einem eigenen Fehlercode verbirgt); gibt den Status aus.
|
||||||
|
nc_put() {
|
||||||
|
curl -s -o "$E2E_TMP/nc-put.out" -w '%{http_code}' -u "$ANNA" -X PUT \
|
||||||
|
-H 'OCS-APIRequest: true' -H 'Accept: application/json' \
|
||||||
|
--data-urlencode "$2=$3" "$NC_OCS/shares/$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Direkt bei der Nextcloud: ben teilt einen Ordner mit anna (nur Ansehen); gibt den Status aus.
|
||||||
|
ben_share_with_anna() {
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' -u "$BEN" -X MKCOL "$NC_DAV/files/ben/$1" >/dev/null
|
||||||
|
curl -s -o "$E2E_TMP/ben-share.out" -w '%{http_code}' -u "$BEN" -X POST \
|
||||||
|
-H 'OCS-APIRequest: true' -H 'Accept: application/json' \
|
||||||
|
--data-urlencode "path=/$1" -d shareType=0 -d shareWith=anna -d permissions=1 "$NC_OCS/shares"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Aufbau --------------------------------------------------------------------------------------
|
# --- Aufbau --------------------------------------------------------------------------------------
|
||||||
e2e_wait_health
|
e2e_wait_health
|
||||||
e2e_login "$ADMIN"
|
e2e_login "$ADMIN"
|
||||||
@@ -209,8 +261,194 @@ section_people() {
|
|||||||
echo "e2e shares people ok"
|
echo "e2e shares people ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
section_links() { echo "e2e shares links: folgt in Aufgabe 2"; }
|
section_links() {
|
||||||
section_received() { echo "e2e shares received: folgt in Aufgabe 2"; }
|
local code id1 id2 id3 id4 before pw plus3 plus30 measured ids urls=""
|
||||||
|
|
||||||
|
# Ausgangslage: keine Pflicht, kein Ablauf (Zustand des Skripts, nicht der Vorlauf)
|
||||||
|
NC_OCC config:app:delete core shareapi_enforce_links_password >/dev/null 2>&1 || true
|
||||||
|
policy_wait 'd["links"]["enabled"] is True and d["links"]["passwordRequired"] is False and d["links"]["expiryEnforced"] is False and d["links"]["expiryDefaultDays"] is None' "Ausgangslage: Links an, keine Pflicht"
|
||||||
|
|
||||||
|
# Link ohne Passwort und ohne Ablauf (freiwillig)
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"\"}")
|
||||||
|
e2e_expect 201 "$code" "Link anlegen"
|
||||||
|
e2e_expect True "$(jq_py 'd["kind"]=="link" and d["hasPassword"] is False and d["expiration"] is None and d["access"]=="view" and d["permissions"] & 15 == 1')" "Link: Ansehen, ohne Passwort, ohne Ablauf"
|
||||||
|
e2e_expect True "$(jq_py 'd["url"].startswith("'"$NC_BASE"'/")')" "Link-Adresse beginnt mit der Nextcloud-Adresse"
|
||||||
|
id1=$(jq_py 'd["id"]')
|
||||||
|
urls="$urls $(jq_py 'd["url"]')"
|
||||||
|
|
||||||
|
# Nur hochladen bei einem Ordner
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX/Briefkasten\",\"kind\":\"link\",\"access\":\"upload\",\"expireDate\":\"\"}")
|
||||||
|
e2e_expect 201 "$code" "Briefkasten-Link anlegen"
|
||||||
|
e2e_expect True "$(jq_py 'd["permissions"] & 15 == 4 and d["access"]=="upload"')" "Briefkasten-Link: Berechtigung 4 (die Nextcloud setzt bei Links das Teilen-Bit dazu)"
|
||||||
|
id2=$(jq_py 'd["id"]')
|
||||||
|
urls="$urls $(jq_py 'd["url"]')"
|
||||||
|
|
||||||
|
# Nur hochladen bei einer Datei: abgelehnt, nichts bei der Nextcloud
|
||||||
|
before=$(anna_link_count "/$FIX/Bericht.txt")
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX/Bericht.txt\",\"kind\":\"link\",\"access\":\"upload\",\"expireDate\":\"\"}")
|
||||||
|
e2e_expect 400 "$code" "Nur hochladen bei einer Datei"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareAccessInvalid"' "Datei-Upload: shareAccessInvalid"
|
||||||
|
e2e_expect "$before" "$(anna_link_count "/$FIX/Bericht.txt")" "Nextcloud hat keinen neuen Link fuer die Datei"
|
||||||
|
|
||||||
|
# --- Passwort wird von der Nextcloud verlangt ---
|
||||||
|
NC_OCC config:app:set core shareapi_enforce_links_password --value=true --type=boolean >/dev/null
|
||||||
|
policy_wait 'd["links"]["passwordRequired"] is True' "Richtlinie: Passwort verlangt"
|
||||||
|
|
||||||
|
before=$(anna_link_count "/$FIX")
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"\"}")
|
||||||
|
e2e_expect 400 "$code" "Link ohne Passwort bei Pflicht"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"sharePasswordRequired"' "ohne Passwort: sharePasswordRequired"
|
||||||
|
e2e_expect "$before" "$(anna_link_count "/$FIX")" "Nextcloud hat keinen neuen Link (Passwort fehlte)"
|
||||||
|
|
||||||
|
pw="Tessera-E2E-$(python3 -I -c 'import secrets,string;print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(16)))')"
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"password\":\"$pw\",\"expireDate\":\"\",\"label\":\"Kunde\"}")
|
||||||
|
e2e_expect 201 "$code" "Link mit starkem Passwort"
|
||||||
|
e2e_expect True "$(jq_py 'd["hasPassword"] is True and d["label"]=="Kunde"')" "Link: hasPassword, Bezeichnung"
|
||||||
|
if grep -qF -- "$pw" "$E2E_TMP/body.out"; then e2e_fail "das Passwort steht in der Antwort"; fi
|
||||||
|
id3=$(jq_py 'd["id"]')
|
||||||
|
urls="$urls $(jq_py 'd["url"]')"
|
||||||
|
|
||||||
|
code=$(call PUT "shares/$id3" '{"password":"abc"}')
|
||||||
|
e2e_expect 400 "$code" "schwaches Passwort beim Aendern"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"sharePasswordRejected"' "schwach: sharePasswordRejected"
|
||||||
|
e2e_expect True "$(jq_py 'bool(d.get("ncMessage"))')" "schwach: Meldung der Nextcloud vorhanden"
|
||||||
|
measured=$(nc_put "$id3" password abc)
|
||||||
|
echo "gemessen: Nextcloud antwortet auf PUT mit schwachem Passwort mit Status $measured"
|
||||||
|
|
||||||
|
code=$(call PUT "shares/$id3" '{"password":""}')
|
||||||
|
e2e_expect 400 "$code" "Passwort entfernen bei Pflicht"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"sharePasswordRequired"' "entfernen: sharePasswordRequired"
|
||||||
|
|
||||||
|
NC_OCC config:app:delete core shareapi_enforce_links_password >/dev/null
|
||||||
|
policy_wait 'd["links"]["passwordRequired"] is False' "Richtlinie: Passwort wieder freiwillig"
|
||||||
|
|
||||||
|
# --- Ablaufdatum wird von der Nextcloud verlangt (7 Tage) ---
|
||||||
|
NC_OCC config:app:set core shareapi_default_expire_date --value=true --type=boolean >/dev/null
|
||||||
|
NC_OCC config:app:set core shareapi_enforce_expire_date --value=true --type=boolean >/dev/null
|
||||||
|
NC_OCC config:app:set core shareapi_expire_after_n_days --value=7 >/dev/null
|
||||||
|
policy_wait 'd["links"]["expiryDefaultDays"]==7 and d["links"]["expiryEnforced"] is True' "Richtlinie: Ablauf 7 Tage verlangt"
|
||||||
|
|
||||||
|
before=$(anna_link_count "/$FIX")
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"\"}")
|
||||||
|
e2e_expect 400 "$code" "Link ohne Ablauf bei Pflicht"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryRequired"' "ohne Ablauf: shareExpiryRequired"
|
||||||
|
e2e_expect "$before" "$(anna_link_count "/$FIX")" "Nextcloud hat keinen neuen Link (Ablauf fehlte)"
|
||||||
|
|
||||||
|
plus3=$(date -u -d '+3 days' +%F)
|
||||||
|
plus30=$(date -u -d '+30 days' +%F)
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"$plus3\"}")
|
||||||
|
e2e_expect 201 "$code" "Link mit Ablauf in 3 Tagen"
|
||||||
|
e2e_expect "$plus3" "$(jq_py 'd["expiration"]')" "Link: Ablauf wie gesendet"
|
||||||
|
id4=$(jq_py 'd["id"]')
|
||||||
|
urls="$urls $(jq_py 'd["url"]')"
|
||||||
|
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"$plus30\"}")
|
||||||
|
echo "gemessen: Tessera antwortet auf ein neues Datum jenseits des Hoechstwerts mit Status $code"
|
||||||
|
e2e_expect 400 "$code" "Link mit Ablauf jenseits des Hoechstwerts"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryInvalid"' "zu spaet: shareExpiryInvalid"
|
||||||
|
|
||||||
|
code=$(call PUT "shares/$id4" "{\"expireDate\":\"$plus30\"}")
|
||||||
|
e2e_expect 400 "$code" "Ablauf jenseits des Hoechstwerts beim Aendern"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryInvalid"' "Aendern, zu spaet: shareExpiryInvalid"
|
||||||
|
measured=$(nc_put "$id4" expireDate "$plus30")
|
||||||
|
echo "gemessen: Nextcloud antwortet auf PUT mit Ablauf jenseits des Hoechstwerts mit Status $measured"
|
||||||
|
|
||||||
|
code=$(call PUT "shares/$id4" '{"expireDate":""}')
|
||||||
|
e2e_expect 400 "$code" "Ablauf entfernen bei Pflicht"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryRequired"' "Ablauf entfernen: shareExpiryRequired"
|
||||||
|
|
||||||
|
before=$(anna_link_count "/$FIX")
|
||||||
|
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"31.12.2026x\"}")
|
||||||
|
e2e_expect 400 "$code" "Datum in falschem Format"
|
||||||
|
e2e_expect "$before" "$(anna_link_count "/$FIX")" "Nextcloud hat keinen neuen Link (Datum ungueltig)"
|
||||||
|
|
||||||
|
for key in shareapi_default_expire_date shareapi_enforce_expire_date shareapi_expire_after_n_days; do
|
||||||
|
NC_OCC config:app:delete core "$key" >/dev/null
|
||||||
|
done
|
||||||
|
policy_wait 'd["links"]["expiryDefaultDays"] is None and d["links"]["expiryEnforced"] is False' "Richtlinie: Ablauf wieder frei"
|
||||||
|
|
||||||
|
# Uebersicht und Aufraeumen
|
||||||
|
code=$(call GET shares/mine)
|
||||||
|
e2e_expect 200 "$code" "Eigene Freigaben"
|
||||||
|
ids="$id1 $id2 $id3 $id4"
|
||||||
|
for id in $ids; do
|
||||||
|
e2e_expect True "$(jq_py 'any(s["id"]=="'"$id"'" and s["kind"]=="link" for s in d["shares"])')" "mine enthaelt Link $id"
|
||||||
|
done
|
||||||
|
for id in $ids; do
|
||||||
|
code=$(call DELETE "shares/$id")
|
||||||
|
e2e_expect 200 "$code" "Link $id loeschen"
|
||||||
|
done
|
||||||
|
e2e_expect 0 "$(anna_link_count "/$FIX")" "alle Links der Fixture sind weg"
|
||||||
|
|
||||||
|
# Weder Passwort noch Link-Adresse im Protokoll der API
|
||||||
|
local log
|
||||||
|
log=$(cd "$REPO" && docker compose logs api --since "$START_ISO" 2>&1 || true)
|
||||||
|
case "$log" in *"$pw"*) e2e_fail "das Passwort steht im Protokoll der API" ;; esac
|
||||||
|
for u in $urls; do
|
||||||
|
case "$log" in *"$u"*) e2e_fail "eine Link-Adresse steht im Protokoll der API" ;; esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "e2e shares links ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
section_received() {
|
||||||
|
local code id sfx_letters pid
|
||||||
|
|
||||||
|
# Eingehend und angenommen (Vorgabe der Nextcloud: sofort angenommen)
|
||||||
|
NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 || true
|
||||||
|
code=$(ben_share_with_anna "$BENFIX")
|
||||||
|
e2e_expect 200 "$code" "ben teilt einen Ordner mit anna"
|
||||||
|
|
||||||
|
code=$(call GET shares/received)
|
||||||
|
e2e_expect 200 "$code" "Mit mir geteilt"
|
||||||
|
e2e_expect True "$(jq_py 'any(s["target"]=="/'"$BENFIX"'" and s["ownerName"]=="Ben Beispiel" and s["canEdit"] is False and s["kind"]=="user" for s in d["shares"])')" "received: Ordner von Ben Beispiel, nicht aenderbar"
|
||||||
|
id=$(jq_py '[s["id"] for s in d["shares"] if s["target"]=="/'"$BENFIX"'"][0]')
|
||||||
|
|
||||||
|
code=$(qget files path /)
|
||||||
|
e2e_expect 200 "$code" "Wurzel auflisten"
|
||||||
|
sfx_letters=$(jq_py '[e["permissions"] for e in d["entries"] if e["name"]=="'"$BENFIX"'"][0]')
|
||||||
|
echo "gemessen: Berechtigungsbuchstaben des eingehenden Ordners: $sfx_letters"
|
||||||
|
case "$sfx_letters" in *S*) ;; *) e2e_fail "eingehender Eintrag ohne Buchstaben S ($sfx_letters): Kennzeichen auf nc:mount-type umstellen" ;; esac
|
||||||
|
|
||||||
|
code=$(call GET shares/mine)
|
||||||
|
e2e_expect 200 "$code" "Eigene Freigaben"
|
||||||
|
e2e_expect True "$(jq_py 'not any(s["target"]=="/'"$BENFIX"'" or s["path"]=="/'"$BENFIX"'" for s in d["shares"])')" "mine enthaelt die eingehende Freigabe nicht"
|
||||||
|
|
||||||
|
# Verlassen (DELETE durch die Empfaengerin)
|
||||||
|
code=$(call DELETE "shares/$id")
|
||||||
|
e2e_expect 200 "$code" "Freigabe verlassen"
|
||||||
|
code=$(call GET shares/received)
|
||||||
|
e2e_expect True "$(jq_py 'not any(s["target"]=="/'"$BENFIX"'" for s in d["shares"]+d["pending"])')" "received: die verlassene Freigabe ist weg"
|
||||||
|
curl -s -u "$BEN" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -G \
|
||||||
|
--data-urlencode "path=/$BENFIX" "$NC_OCS/shares" -o "$E2E_TMP/ben-after.out"
|
||||||
|
echo "gemessen: bens eigene Liste nach dem Verlassen: $(python3 -I -c 'import json,sys
|
||||||
|
d=json.load(open(sys.argv[1]))["ocs"]["data"]
|
||||||
|
print(len(d), "Freigaben", [(s.get("share_type"), s.get("share_with"), s.get("permissions")) for s in d])' "$E2E_TMP/ben-after.out")"
|
||||||
|
|
||||||
|
# Offene Freigabe: annas Vorgabe auf "nicht automatisch annehmen"
|
||||||
|
NC_OCC user:setting anna files_sharing default_accept no >/dev/null
|
||||||
|
code=$(ben_share_with_anna "$BENBOX")
|
||||||
|
e2e_expect 200 "$code" "ben teilt den Briefkasten mit anna"
|
||||||
|
code=$(call GET shares/received)
|
||||||
|
e2e_expect 200 "$code" "Mit mir geteilt (offen)"
|
||||||
|
e2e_expect True "$(jq_py 'any(s["name"].startswith("'"$BENBOX"'") and s.get("pending") is True for s in d["pending"]) and not any(s["name"].startswith("'"$BENBOX"'") for s in d["shares"])')" "received: offene Freigabe nur unter pending"
|
||||||
|
pid=$(jq_py '[s["id"] for s in d["pending"] if s["name"].startswith("'"$BENBOX"'")][0]')
|
||||||
|
|
||||||
|
code=$(call POST "shares/$pid/accept")
|
||||||
|
e2e_expect 200 "$code" "offene Freigabe annehmen"
|
||||||
|
e2e_expect True "$(jq_py 'd["accepted"] is True')" "accept: accepted"
|
||||||
|
code=$(call GET shares/received)
|
||||||
|
e2e_expect True "$(jq_py 'any(s["name"].startswith("'"$BENBOX"'") for s in d["shares"]) and not d["pending"]')" "received: angenommen, nichts mehr offen"
|
||||||
|
|
||||||
|
code=$(call DELETE "shares/$pid")
|
||||||
|
e2e_expect 200 "$code" "angenommene Freigabe verlassen"
|
||||||
|
code=$(call POST "shares/abc/accept")
|
||||||
|
e2e_expect 404 "$code" "annehmen mit ungueltiger Kennung"
|
||||||
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "accept abc: shareNotFound"
|
||||||
|
NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "e2e shares received ok"
|
||||||
|
}
|
||||||
section_version() { echo "e2e shares version: folgt in Aufgabe 3"; }
|
section_version() { echo "e2e shares version: folgt in Aufgabe 3"; }
|
||||||
|
|
||||||
case "$SECTION" in
|
case "$SECTION" in
|
||||||
|
|||||||
@@ -168,8 +168,11 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
|||||||
'listSharesForPath',
|
'listSharesForPath',
|
||||||
'searchSharees',
|
'searchSharees',
|
||||||
'createShare',
|
'createShare',
|
||||||
|
'listMyShares',
|
||||||
|
'listReceivedShares',
|
||||||
'updateShare',
|
'updateShare',
|
||||||
'deleteShare',
|
'deleteShare',
|
||||||
|
'acceptShare',
|
||||||
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||||
const fn = handler(NextcloudFilesController, name);
|
const fn = handler(NextcloudFilesController, name);
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eingaben der Freigaberouten (quick-261009-dkv, D-11). Der Browser schickt nur
|
* Eingaben der Freigaberouten (quick-261009-dkv, D-11). Der Browser schickt nur
|
||||||
@@ -12,8 +20,11 @@ import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'clas
|
|||||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
|
||||||
const NO_CONTROL = /^[^\u0000-\u001f\u007f]*$/;
|
const NO_CONTROL = /^[^\u0000-\u001f\u007f]*$/;
|
||||||
|
|
||||||
export const SHARE_KINDS_PEOPLE = ['user', 'group'] as const;
|
export const SHARE_KINDS = ['user', 'group', 'link'] as const;
|
||||||
export const SHARE_ACCESS = ['view', 'edit', 'upload'] as const;
|
export const SHARE_ACCESS = ['view', 'edit', 'upload'] as const;
|
||||||
|
/** Ablaufdatum: leer (ohne Ablauf) oder `YYYY-MM-DD`; ob es ein echtes Datum ist, prueft der Dienst. */
|
||||||
|
const EXPIRE_DATE = /^(\d{4}-\d{2}-\d{2})?$/;
|
||||||
|
|
||||||
export const ITEM_TYPES = ['file', 'folder'] as const;
|
export const ITEM_TYPES = ['file', 'folder'] as const;
|
||||||
|
|
||||||
export class ShareByPathQueryDto {
|
export class ShareByPathQueryDto {
|
||||||
@@ -40,21 +51,58 @@ export class CreateShareDto {
|
|||||||
@MaxLength(4096)
|
@MaxLength(4096)
|
||||||
path!: string;
|
path!: string;
|
||||||
|
|
||||||
@IsIn(SHARE_KINDS_PEOPLE)
|
@IsIn(SHARE_KINDS)
|
||||||
kind!: (typeof SHARE_KINDS_PEOPLE)[number];
|
kind!: (typeof SHARE_KINDS)[number];
|
||||||
|
|
||||||
|
/** Nur bei Personen und Gruppen; bei Links ohne Bedeutung. */
|
||||||
|
@ValidateIf((o: CreateShareDto) => o.kind !== 'link')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
@Matches(NO_CONTROL)
|
@Matches(NO_CONTROL)
|
||||||
shareWith!: string;
|
shareWith?: string;
|
||||||
|
|
||||||
@IsIn(SHARE_ACCESS)
|
@IsIn(SHARE_ACCESS)
|
||||||
access!: (typeof SHARE_ACCESS)[number];
|
access!: (typeof SHARE_ACCESS)[number];
|
||||||
|
|
||||||
|
/** Nur Links. Nie in einer Adresse, nie in einer Antwort oder einem Log. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(EXPIRE_DATE)
|
||||||
|
expireDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@Matches(NO_CONTROL)
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateShareDto {
|
export class UpdateShareDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(SHARE_ACCESS)
|
@IsIn(SHARE_ACCESS)
|
||||||
access?: (typeof SHARE_ACCESS)[number];
|
access?: (typeof SHARE_ACCESS)[number];
|
||||||
|
|
||||||
|
/** Leer = Passwort entfernen (der Dienst prueft, ob die Nextcloud ein Passwort verlangt). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
/** Leer = Ablaufdatum entfernen. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(EXPIRE_DATE)
|
||||||
|
expireDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@Matches(NO_CONTROL)
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -518,3 +518,476 @@ describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => {
|
|||||||
expect([401, 403]).not.toContain(statusOf(e));
|
expect([401, 403]).not.toContain(statusOf(e));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Links (quick-261009-dkv, Aufgabe 2) -----------------------------------------------------------
|
||||||
|
|
||||||
|
const PW = 'Geheim-Pass-2026!';
|
||||||
|
|
||||||
|
function linkJson(over: Record<string, unknown> = {}) {
|
||||||
|
return shareJson({
|
||||||
|
id: '31',
|
||||||
|
share_type: 3,
|
||||||
|
share_with: null,
|
||||||
|
share_with_displayname: null,
|
||||||
|
permissions: 1,
|
||||||
|
token: 'AbC123',
|
||||||
|
url: 'https://cloud.example/nc/index.php/s/AbC123',
|
||||||
|
password: 'redacted',
|
||||||
|
label: 'Kunde',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faehigkeiten mit Link-Regeln: `public` wird mit den angegebenen Werten ueberlagert. */
|
||||||
|
const LINK_CAPS = (pub: Record<string, unknown> = {}, over: Record<string, unknown> = {}) =>
|
||||||
|
CAPS({ public: { enabled: true, upload: true, ...pub }, ...over });
|
||||||
|
|
||||||
|
const LINK_INPUT = {
|
||||||
|
path: '/Projekte',
|
||||||
|
kind: 'link',
|
||||||
|
access: 'view',
|
||||||
|
password: PW,
|
||||||
|
expireDate: '',
|
||||||
|
label: 'Kunde',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function linkReplies(
|
||||||
|
over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {},
|
||||||
|
withList = false,
|
||||||
|
): Reply[] {
|
||||||
|
const replies: Reply[] = [
|
||||||
|
over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') },
|
||||||
|
over.caps ?? { status: 200, text: LINK_CAPS() },
|
||||||
|
];
|
||||||
|
if (withList) replies.push(over.list ?? { status: 200, text: ocs([]) });
|
||||||
|
replies.push(over.post ?? { status: 200, text: ocs(linkJson()) });
|
||||||
|
return replies;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NextcloudFilesSharesService — Link anlegen', () => {
|
||||||
|
it('sendet genau den erwarteten JSON-Koerper und gibt weder Passwort noch Kennung heraus', async () => {
|
||||||
|
const { service, calls } = setup(linkReplies());
|
||||||
|
const share = await service.create('t1', 'u1', LINK_INPUT);
|
||||||
|
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
||||||
|
`PROPFIND ${DAV}/Projekte`,
|
||||||
|
`GET ${CAPS_URL}`,
|
||||||
|
`POST ${SHARES}`,
|
||||||
|
]);
|
||||||
|
expect(calls[2].body).toBe(
|
||||||
|
'{"path":"/Projekte","shareType":3,"permissions":1,"password":"Geheim-Pass-2026!","expireDate":"","label":"Kunde"}',
|
||||||
|
);
|
||||||
|
expect(share).toMatchObject({
|
||||||
|
id: '31',
|
||||||
|
kind: 'link',
|
||||||
|
hasPassword: true,
|
||||||
|
url: 'https://cloud.example/nc/index.php/s/AbC123',
|
||||||
|
label: 'Kunde',
|
||||||
|
});
|
||||||
|
const text = JSON.stringify(share);
|
||||||
|
expect(text).not.toContain(PW);
|
||||||
|
expect(text).not.toContain('redacted');
|
||||||
|
expect(text).not.toContain('"token"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Passwort, Ablauf und Bezeichnung werden diese Felder nicht gesendet', async () => {
|
||||||
|
const { service, calls } = setup(linkReplies());
|
||||||
|
await service.create('t1', 'u1', { path: '/Projekte', kind: 'link', access: 'view' });
|
||||||
|
expect(calls[2].body).toBe('{"path":"/Projekte","shareType":3,"permissions":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nur hochladen bei einem Ordner sendet 4; bei einer Datei und ohne erlaubtes Hochladen: shareAccessInvalid ohne POST', async () => {
|
||||||
|
const ok = setup(linkReplies());
|
||||||
|
await ok.service.create('t1', 'u1', { ...LINK_INPUT, access: 'upload' });
|
||||||
|
expect(ok.calls[2].body).toContain('"permissions":4');
|
||||||
|
|
||||||
|
const file = setup(
|
||||||
|
linkReplies({ stat: { status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') } }),
|
||||||
|
);
|
||||||
|
const e1 = await failure(
|
||||||
|
file.service.create('t1', 'u1', { ...LINK_INPUT, path: '/Bericht.txt', access: 'upload' }),
|
||||||
|
);
|
||||||
|
expect(codeOf(e1)).toBe('shareAccessInvalid');
|
||||||
|
expect(statusOf(e1)).toBe(400);
|
||||||
|
expect(file.calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
|
||||||
|
for (const access of ['edit', 'upload'] as const) {
|
||||||
|
const off = setup(linkReplies({ caps: { status: 200, text: LINK_CAPS({ upload: false }) } }));
|
||||||
|
const e2 = await failure(off.service.create('t1', 'u1', { ...LINK_INPUT, access }));
|
||||||
|
expect(codeOf(e2)).toBe('shareAccessInvalid');
|
||||||
|
expect(off.calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ordner ohne Schreibrecht (RG): Nur hochladen und Bearbeiten sind shareAccessInvalid', async () => {
|
||||||
|
for (const access of ['edit', 'upload'] as const) {
|
||||||
|
const { service, calls } = setup(
|
||||||
|
linkReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }),
|
||||||
|
);
|
||||||
|
const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, access }));
|
||||||
|
expect(codeOf(e)).toBe('shareAccessInvalid');
|
||||||
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Links ausgeschaltet: 409 linkSharingDisabled', async () => {
|
||||||
|
const { service, calls } = setup(
|
||||||
|
linkReplies({ caps: { status: 200, text: CAPS({ public: { enabled: false } }) } }),
|
||||||
|
);
|
||||||
|
const e = await failure(service.create('t1', 'u1', LINK_INPUT));
|
||||||
|
expect(codeOf(e)).toBe('linkSharingDisabled');
|
||||||
|
expect(statusOf(e)).toBe(409);
|
||||||
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur ein Link erlaubt und schon einer da: 409 shareAlreadyExists; mehrere erlaubt: keine Listenabfrage', async () => {
|
||||||
|
const one = setup(
|
||||||
|
linkReplies(
|
||||||
|
{
|
||||||
|
caps: { status: 200, text: LINK_CAPS({ multiple_links: false }) },
|
||||||
|
list: { status: 200, text: ocs([linkJson()]) },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const e = await failure(one.service.create('t1', 'u1', LINK_INPUT));
|
||||||
|
expect(codeOf(e)).toBe('shareAlreadyExists');
|
||||||
|
expect(one.calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
|
||||||
|
const many = setup(linkReplies());
|
||||||
|
await many.service.create('t1', 'u1', LINK_INPUT);
|
||||||
|
expect(many.calls.some((c) => c.url === BY_PATH)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Passwort verlangt und keins gesendet: 400 sharePasswordRequired nach nur zwei Aufrufen', async () => {
|
||||||
|
for (const password of [undefined, '']) {
|
||||||
|
const { service, calls } = setup(
|
||||||
|
linkReplies({
|
||||||
|
caps: { status: 200, text: LINK_CAPS({ password: { enforced: true } }) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, password }));
|
||||||
|
expect(codeOf(e)).toBe('sharePasswordRequired');
|
||||||
|
expect(statusOf(e)).toBe(400);
|
||||||
|
expect(calls.map((c) => c.method)).toEqual(['PROPFIND', 'GET']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ablauf verlangt und fehlend oder leer: 400 shareExpiryRequired ohne POST', async () => {
|
||||||
|
for (const expireDate of [undefined, '']) {
|
||||||
|
const { service, calls } = setup(
|
||||||
|
linkReplies({
|
||||||
|
caps: {
|
||||||
|
status: 200,
|
||||||
|
text: LINK_CAPS({ expire_date: { enabled: true, days: '7', enforced: true } }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, expireDate }));
|
||||||
|
expect(codeOf(e)).toBe('shareExpiryRequired');
|
||||||
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ablauf verlangt und ein Datum gesendet: das Datum steht im Koerper', async () => {
|
||||||
|
const { service, calls } = setup(
|
||||||
|
linkReplies({
|
||||||
|
caps: {
|
||||||
|
status: 200,
|
||||||
|
text: LINK_CAPS({ expire_date: { enabled: true, days: '7', enforced: true } }),
|
||||||
|
},
|
||||||
|
post: { status: 200, text: ocs(linkJson({ expiration: '2026-12-01 00:00:00' })) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const share = await service.create('t1', 'u1', { ...LINK_INPUT, expireDate: '2026-12-01' });
|
||||||
|
expect(calls[2].body).toContain('"expireDate":"2026-12-01"');
|
||||||
|
expect(share.expiration).toBe('2026-12-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kein echtes Datum ('2026-02-30'): 400 shareExpiryInvalid ohne POST", async () => {
|
||||||
|
const { service, calls } = setup(linkReplies());
|
||||||
|
const e = await failure(
|
||||||
|
service.create('t1', 'u1', { ...LINK_INPUT, expireDate: '2026-02-30' }),
|
||||||
|
);
|
||||||
|
expect(codeOf(e)).toBe('shareExpiryInvalid');
|
||||||
|
expect(statusOf(e)).toBe(400);
|
||||||
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Passwort, Ablauf und Bezeichnung werden bei Personen und Gruppen nie weitergegeben', async () => {
|
||||||
|
const { service, calls } = setup(createReplies());
|
||||||
|
await service.create('t1', 'u1', {
|
||||||
|
...INPUT,
|
||||||
|
password: PW,
|
||||||
|
expireDate: '2026-12-01',
|
||||||
|
label: 'x',
|
||||||
|
});
|
||||||
|
expect(calls[3].body).toBe(
|
||||||
|
'{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Person oder Gruppe ohne Empfaenger: 422 shareRecipientInvalid ohne Aufruf', async () => {
|
||||||
|
const { service, calls } = setup([]);
|
||||||
|
const e = await failure(
|
||||||
|
service.create('t1', 'u1', { path: '/Projekte', kind: 'user', access: 'view' }),
|
||||||
|
);
|
||||||
|
expect(codeOf(e)).toBe('shareRecipientInvalid');
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[400, 'Password is too short', { password: PW, expireDate: '' }, 'sharePasswordRejected', 400],
|
||||||
|
[
|
||||||
|
404,
|
||||||
|
'Expiration date is in the past',
|
||||||
|
{ password: PW, expireDate: '2026-12-01' },
|
||||||
|
'shareExpiryInvalid',
|
||||||
|
400,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
403,
|
||||||
|
'Passwords are enforced for link and mail shares',
|
||||||
|
{ password: PW, expireDate: '' },
|
||||||
|
'shareRejected',
|
||||||
|
422,
|
||||||
|
],
|
||||||
|
[404, 'Could not create share', { password: PW, expireDate: '' }, 'notFound', 404],
|
||||||
|
] as const)('POST antwortet %i (%s) -> %s', async (nc, message, extra, code, http) => {
|
||||||
|
const { service } = setup(linkReplies({ post: { status: nc, text: ocs([], nc, message) } }));
|
||||||
|
const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, ...extra }));
|
||||||
|
expect(codeOf(e)).toBe(code);
|
||||||
|
expect(statusOf(e)).toBe(http);
|
||||||
|
expect([401, 403]).not.toContain(statusOf(e));
|
||||||
|
if (code !== 'notFound') expect(bodyOf(e).ncMessage).toBe(message);
|
||||||
|
expect(JSON.stringify(bodyOf(e))).not.toContain(PW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NextcloudFilesSharesService — Link aendern', () => {
|
||||||
|
const PUT31 = `PUT ${SHARES}/31`;
|
||||||
|
const linkWith = (over: Record<string, unknown> = {}) => ({
|
||||||
|
status: 200,
|
||||||
|
text: ocs([linkJson(over)]),
|
||||||
|
});
|
||||||
|
const caps = (pub: Record<string, unknown> = {}): Reply => ({
|
||||||
|
status: 200,
|
||||||
|
text: LINK_CAPS(pub),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendet nur die geaenderten Felder', async () => {
|
||||||
|
const cases: Array<[Record<string, unknown>, string]> = [
|
||||||
|
[{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'],
|
||||||
|
[{ expireDate: '' }, '{"expireDate":""}'],
|
||||||
|
[{ access: 'upload' }, '{"permissions":4}'],
|
||||||
|
[{ label: 'Angebot' }, '{"label":"Angebot"}'],
|
||||||
|
];
|
||||||
|
for (const [input, body] of cases) {
|
||||||
|
const { service, calls } = setup([
|
||||||
|
linkWith({ expiration: '2026-12-01 00:00:00' }),
|
||||||
|
caps(),
|
||||||
|
{ status: 200, text: ocs(linkJson()) },
|
||||||
|
]);
|
||||||
|
await service.update('t1', 'u1', '31', input);
|
||||||
|
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
||||||
|
`GET ${SHARES}/31`,
|
||||||
|
`GET ${CAPS_URL}`,
|
||||||
|
PUT31,
|
||||||
|
]);
|
||||||
|
expect(calls[2].body).toBe(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unveraenderte Werte ergeben kein PUT', async () => {
|
||||||
|
const { service, calls } = setup([
|
||||||
|
linkWith({ expiration: '2026-12-01 00:00:00', label: 'Kunde', password: null }),
|
||||||
|
caps(),
|
||||||
|
]);
|
||||||
|
await service.update('t1', 'u1', '31', {
|
||||||
|
access: 'view',
|
||||||
|
expireDate: '2026-12-01',
|
||||||
|
label: 'Kunde',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Passwort '' bei verlangtem Passwort: 400 sharePasswordRequired ohne PUT", async () => {
|
||||||
|
const { service, calls } = setup([linkWith(), caps({ password: { enforced: true } })]);
|
||||||
|
const e = await failure(service.update('t1', 'u1', '31', { password: '' }));
|
||||||
|
expect(codeOf(e)).toBe('sharePasswordRequired');
|
||||||
|
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ablauf '' bei verlangtem Ablauf: 400 shareExpiryRequired ohne PUT", async () => {
|
||||||
|
const { service, calls } = setup([
|
||||||
|
linkWith({ expiration: '2026-12-01 00:00:00' }),
|
||||||
|
caps({ expire_date: { enabled: true, days: '7', enforced: true } }),
|
||||||
|
]);
|
||||||
|
const e = await failure(service.update('t1', 'u1', '31', { expireDate: '' }));
|
||||||
|
expect(codeOf(e)).toBe('shareExpiryRequired');
|
||||||
|
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Passwort '' ohne Pflicht entfernt das Passwort (nur wenn eins gesetzt ist)", async () => {
|
||||||
|
const { service, calls } = setup([
|
||||||
|
linkWith(),
|
||||||
|
caps(),
|
||||||
|
{ status: 200, text: ocs(linkJson({ password: null })) },
|
||||||
|
]);
|
||||||
|
const share = await service.update('t1', 'u1', '31', { password: '' });
|
||||||
|
expect(calls[2].body).toBe('{"password":""}');
|
||||||
|
expect(share.hasPassword).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Links ausgeschaltet: 409 linkSharingDisabled', async () => {
|
||||||
|
const { service } = setup([
|
||||||
|
linkWith(),
|
||||||
|
{ status: 200, text: CAPS({ public: { enabled: false } }) },
|
||||||
|
]);
|
||||||
|
expect(codeOf(await failure(service.update('t1', 'u1', '31', { label: 'x' })))).toBe(
|
||||||
|
'linkSharingDisabled',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bearbeiten oder Hochladen ohne erlaubtes Hochladen: shareAccessInvalid', async () => {
|
||||||
|
const { service } = setup([linkWith(), caps({ upload: false })]);
|
||||||
|
expect(codeOf(await failure(service.update('t1', 'u1', '31', { access: 'edit' })))).toBe(
|
||||||
|
'shareAccessInvalid',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kein echtes Datum: 400 shareExpiryInvalid ohne PUT', async () => {
|
||||||
|
const { service, calls } = setup([linkWith(), caps()]);
|
||||||
|
const e = await failure(service.update('t1', 'u1', '31', { expireDate: '2026-13-01' }));
|
||||||
|
expect(codeOf(e)).toBe('shareExpiryInvalid');
|
||||||
|
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[400, { password: 'schwach' }, 'sharePasswordRejected', 400],
|
||||||
|
[400, { expireDate: '2027-01-01' }, 'shareExpiryInvalid', 400],
|
||||||
|
[404, { expireDate: '2027-01-01' }, 'shareExpiryInvalid', 400],
|
||||||
|
[404, { label: 'x' }, 'shareNotFound', 404],
|
||||||
|
[400, { label: 'x' }, 'shareRejected', 422],
|
||||||
|
[403, { label: 'x' }, 'shareRejected', 422],
|
||||||
|
] as const)('PUT antwortet %i mit %j -> %s', async (nc, input, code, http) => {
|
||||||
|
const { service } = setup([
|
||||||
|
linkWith(),
|
||||||
|
caps(),
|
||||||
|
{ status: nc, text: ocs([], nc, 'Failed to update share.') },
|
||||||
|
]);
|
||||||
|
const e = await failure(service.update('t1', 'u1', '31', input));
|
||||||
|
expect(codeOf(e)).toBe(code);
|
||||||
|
expect(statusOf(e)).toBe(http);
|
||||||
|
if (code !== 'shareNotFound') expect(bodyOf(e).ncMessage).toBe('Failed to update share.');
|
||||||
|
expect(JSON.stringify(bodyOf(e))).not.toContain('schwach');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bei Personenfreigaben werden Link-Felder verworfen', async () => {
|
||||||
|
const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]);
|
||||||
|
await service.update('t1', 'u1', '17', { password: 'x', expireDate: '2026-12-01', label: 'y' });
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NextcloudFilesSharesService — Uebersichten und Annehmen', () => {
|
||||||
|
const received = (over: Record<string, unknown> = {}) =>
|
||||||
|
shareJson({
|
||||||
|
id: '40',
|
||||||
|
uid_owner: 'ben',
|
||||||
|
displayname_owner: 'Ben Beispiel',
|
||||||
|
share_with: 'anna',
|
||||||
|
path: '/Ben-Ordner',
|
||||||
|
file_target: '/Ben-Ordner',
|
||||||
|
permissions: 1,
|
||||||
|
can_edit: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mine: GET auf die Freigaben ohne Abfrage, Arten 0/1/3, andere nur als Zahl', async () => {
|
||||||
|
const { service, calls } = setup([
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
text: ocs([shareJson(), linkJson(), { share_type: 4, id: '5' }]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const out = await service.mine('t1', 'u1');
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(`${calls[0].method} ${calls[0].url}`).toBe(`GET ${SHARES}`);
|
||||||
|
expect(out.shares.map((s) => s.kind)).toEqual(['user', 'link']);
|
||||||
|
expect(out.hidden).toBe(1);
|
||||||
|
expect(out.truncated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('received: angenommene plus offene Freigaben, nur Arten 0/1, offene mit pending', async () => {
|
||||||
|
const { service, calls } = setup([
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
text: ocs([
|
||||||
|
received(),
|
||||||
|
received({ id: '41', share_type: 1 }),
|
||||||
|
linkJson({ uid_owner: 'ben' }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ status: 200, text: ocs([received({ id: '42', file_target: '/Ben-Briefkasten' })]) },
|
||||||
|
]);
|
||||||
|
const out = await service.received('t1', 'u1');
|
||||||
|
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
||||||
|
`GET ${SHARES}?shared_with_me=true`,
|
||||||
|
`GET ${SHARES}/pending`,
|
||||||
|
]);
|
||||||
|
expect(out.shares.map((s) => s.id)).toEqual(['40', '41']);
|
||||||
|
expect(out.pending.map((s) => [s.id, s.pending])).toEqual([['42', true]]);
|
||||||
|
expect(out.pending[0].name).toBe('Ben-Briefkasten');
|
||||||
|
expect(out.shares.every((s) => s.pending === undefined)).toBe(true);
|
||||||
|
expect(out.hidden).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('received: eine offene Freigabe steht nicht doppelt bei den angenommenen', async () => {
|
||||||
|
const { service } = setup([
|
||||||
|
{ status: 200, text: ocs([received()]) },
|
||||||
|
{ status: 200, text: ocs([received()]) },
|
||||||
|
]);
|
||||||
|
const out = await service.received('t1', 'u1');
|
||||||
|
expect(out.shares).toHaveLength(0);
|
||||||
|
expect(out.pending.map((s) => s.id)).toEqual(['40']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
404, 405,
|
||||||
|
])('received: die offene Liste antwortet %i -> leer, kein Fehler', async (nc) => {
|
||||||
|
const { service } = setup([
|
||||||
|
{ status: 200, text: ocs([received()]) },
|
||||||
|
{ status: nc, text: ocs([], nc, 'nope') },
|
||||||
|
]);
|
||||||
|
const out = await service.received('t1', 'u1');
|
||||||
|
expect(out.pending).toEqual([]);
|
||||||
|
expect(out.shares).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('received: 500 bei der offenen Liste ist ein Fehler, 401 markiert die Verbindung', async () => {
|
||||||
|
const boom = setup([
|
||||||
|
{ status: 200, text: ocs([]) },
|
||||||
|
{ status: 500, text: ocs([], 500, 'x') },
|
||||||
|
]);
|
||||||
|
expect(codeOf(await failure(boom.service.received('t1', 'u1')))).toBe('nextcloudError');
|
||||||
|
const dead = setup([{ status: 401 }]);
|
||||||
|
expect(codeOf(await failure(dead.service.received('t1', 'u1')))).toBe('connectionExpired');
|
||||||
|
expect(dead.account.markExpired).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept: POST auf pending/<Kennung>', async () => {
|
||||||
|
const { service, calls } = setup([{ status: 200, text: ocs([]) }]);
|
||||||
|
await expect(service.accept('t1', 'u1', '17')).resolves.toEqual({ accepted: true });
|
||||||
|
expect(`${calls[0].method} ${calls[0].url}`).toBe(`POST ${SHARES}/pending/17`);
|
||||||
|
expect(calls[0].body).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept: 404 -> shareNotFound, falsche Kennung ohne Aufruf', async () => {
|
||||||
|
const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]);
|
||||||
|
expect(codeOf(await failure(nf.service.accept('t1', 'u1', '17')))).toBe('shareNotFound');
|
||||||
|
const bad = setup([]);
|
||||||
|
expect(codeOf(await failure(bad.service.accept('t1', 'u1', 'x')))).toBe('shareNotFound');
|
||||||
|
expect(bad.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NextcloudLoginGuard } from './nextcloud-login-guard';
|
|||||||
import type { NcEntry } from './nextcloud-propfind';
|
import type { NcEntry } from './nextcloud-propfind';
|
||||||
import {
|
import {
|
||||||
CAPABILITIES_SEGMENTS,
|
CAPABILITIES_SEGMENTS,
|
||||||
|
isRealDate,
|
||||||
isShareId,
|
isShareId,
|
||||||
type NcItemType,
|
type NcItemType,
|
||||||
type NcShareKind,
|
type NcShareKind,
|
||||||
@@ -42,16 +43,27 @@ import { mapNcFailure } from './nextcloud-upstream';
|
|||||||
* Nie loggen: Koerper, Passwoerter, Kennungen, Link-Adressen.
|
* Nie loggen: Koerper, Passwoerter, Kennungen, Link-Adressen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Eingabe fuer eine neue Freigabe an Person oder Gruppe (Links folgen in Aufgabe 2). */
|
/**
|
||||||
|
* Eingabe fuer eine neue Freigabe. Bei Links gelten `password`, `expireDate` und `label`
|
||||||
|
* (`expireDate: ''` heisst "ohne Ablauf" und wird gesendet; fehlt das Feld, gilt die
|
||||||
|
* Voreinstellung der Nextcloud); bei Personen und Gruppen werden diese Felder verworfen.
|
||||||
|
*/
|
||||||
export interface CreateShareInput {
|
export interface CreateShareInput {
|
||||||
path: string;
|
path: string;
|
||||||
kind: 'user' | 'group';
|
kind: 'user' | 'group' | 'link';
|
||||||
shareWith: string;
|
shareWith?: string;
|
||||||
access: 'view' | 'edit' | 'upload';
|
access: 'view' | 'edit' | 'upload';
|
||||||
|
password?: string;
|
||||||
|
expireDate?: string;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leere Zeichenkette heisst bei Passwort/Ablauf "entfernen", bei der Bezeichnung "leeren". */
|
||||||
export interface UpdateShareInput {
|
export interface UpdateShareInput {
|
||||||
access?: 'view' | 'edit' | 'upload';
|
access?: 'view' | 'edit' | 'upload';
|
||||||
|
password?: string;
|
||||||
|
expireDate?: string;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Welche Felder gesendet wurden: bestimmt, wie ein Fehler gedeutet wird (D-15). */
|
/** Welche Felder gesendet wurden: bestimmt, wie ein Fehler gedeutet wird (D-15). */
|
||||||
@@ -273,9 +285,32 @@ export class NextcloudFilesSharesService {
|
|||||||
return /[WCK]/.test(entry.permissions);
|
return /[WCK]/.test(entry.permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Passwort- und Ablaufregeln fuer einen Link; Fehler bevor irgendetwas an Nextcloud geht (D-16). */
|
||||||
|
private checkLinkRules(
|
||||||
|
policy: NcSharePolicy,
|
||||||
|
password: string | undefined,
|
||||||
|
expireDate: string | undefined,
|
||||||
|
mode: 'create' | 'update',
|
||||||
|
): void {
|
||||||
|
// Beim Anlegen fehlt das Passwort, beim Aendern heisst '' "Passwort entfernen".
|
||||||
|
const passwordMissing = mode === 'create' ? !password : password === '';
|
||||||
|
if (policy.links.passwordRequired && passwordMissing) {
|
||||||
|
throw ncErrorDefault('sharePasswordRequired');
|
||||||
|
}
|
||||||
|
if (expireDate !== undefined && expireDate !== '' && !isRealDate(expireDate)) {
|
||||||
|
throw ncErrorDefault('shareExpiryInvalid');
|
||||||
|
}
|
||||||
|
const expiryMissing = mode === 'create' ? !expireDate : expireDate === '';
|
||||||
|
if (policy.links.expiryEnforced && expiryMissing) {
|
||||||
|
throw ncErrorDefault('shareExpiryRequired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, input: CreateShareInput): Promise<NcShareView> {
|
async create(tenantId: string, userId: string, input: CreateShareInput): Promise<NcShareView> {
|
||||||
const segments = parseUserPath(input.path);
|
const segments = parseUserPath(input.path);
|
||||||
if (segments.length === 0) throw ncErrorDefault('invalidPath');
|
if (segments.length === 0) throw ncErrorDefault('invalidPath');
|
||||||
|
const isLink = input.kind === 'link';
|
||||||
|
if (!isLink && !input.shareWith) throw ncErrorDefault('shareRecipientInvalid');
|
||||||
const session = await this.session(tenantId, userId);
|
const session = await this.session(tenantId, userId);
|
||||||
|
|
||||||
// Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01).
|
// Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01).
|
||||||
@@ -292,17 +327,45 @@ export class NextcloudFilesSharesService {
|
|||||||
if (input.kind === 'group' && !policy.groupsEnabled) {
|
if (input.kind === 'group' && !policy.groupsEnabled) {
|
||||||
throw ncErrorDefault('sharingDisabled', { scope: 'groups' }, GROUPS_OFF);
|
throw ncErrorDefault('sharingDisabled', { scope: 'groups' }, GROUPS_OFF);
|
||||||
}
|
}
|
||||||
|
if (isLink && !policy.links.enabled) throw ncErrorDefault('linkSharingDisabled');
|
||||||
if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected');
|
if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected');
|
||||||
if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) {
|
if (
|
||||||
|
(input.access === 'upload' && !isLink) ||
|
||||||
|
(input.access !== 'view' && !this.entryWritable(entry)) ||
|
||||||
|
(isLink && input.access !== 'view' && !policy.links.uploadAllowed)
|
||||||
|
) {
|
||||||
throw ncErrorDefault('shareAccessInvalid');
|
throw ncErrorDefault('shareAccessInvalid');
|
||||||
}
|
}
|
||||||
const permissions = permissionsFor(input.access, entry.type);
|
const permissions = permissionsFor(input.access, entry.type);
|
||||||
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
|
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
|
||||||
|
if (isLink) this.checkLinkRules(policy, input.password, input.expireDate, 'create');
|
||||||
|
|
||||||
const path = pathOf(segments);
|
const path = pathOf(segments);
|
||||||
|
// Doppelte Freigaben wuerden bei Nextcloud die Benachrichtigung erneut ausloesen (T-dkv-09);
|
||||||
|
// bei Links nur noetig, wenn die Nextcloud nur einen Link je Eintrag erlaubt.
|
||||||
|
if (!isLink || !policy.links.multipleLinks) {
|
||||||
const existing = await this.listByPath(tenantId, userId, session, path);
|
const existing = await this.listByPath(tenantId, userId, session, path);
|
||||||
if (existing.shares.some((s) => s.kind === input.kind && s.shareWith === input.shareWith)) {
|
const duplicate = isLink
|
||||||
throw ncErrorDefault('shareAlreadyExists');
|
? existing.shares.some((s) => s.kind === 'link')
|
||||||
|
: existing.shares.some((s) => s.kind === input.kind && s.shareWith === input.shareWith);
|
||||||
|
if (duplicate) throw ncErrorDefault('shareAlreadyExists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: Record<string, unknown> = isLink
|
||||||
|
? { path, shareType: 3, permissions }
|
||||||
|
: { path, shareType: input.kind === 'user' ? 0 : 1, shareWith: input.shareWith, permissions };
|
||||||
|
const sent: SentFields = { kind: input.kind };
|
||||||
|
if (isLink) {
|
||||||
|
if (input.password) {
|
||||||
|
json.password = input.password;
|
||||||
|
sent.passwordNonEmpty = true;
|
||||||
|
}
|
||||||
|
if (input.expireDate !== undefined) {
|
||||||
|
json.expireDate = input.expireDate;
|
||||||
|
sent.expireDateSent = true;
|
||||||
|
sent.expireDateNonEmpty = input.expireDate !== '';
|
||||||
|
}
|
||||||
|
if (input.label) json.label = input.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erst nach jeder Vorpruefung zaehlen: abgelehnte Eingaben verbrauchen kein Kontingent.
|
// Erst nach jeder Vorpruefung zaehlen: abgelehnte Eingaben verbrauchen kein Kontingent.
|
||||||
@@ -311,18 +374,9 @@ export class NextcloudFilesSharesService {
|
|||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
session,
|
session,
|
||||||
{
|
{ method: 'POST', segments: SHARES_BASE_SEGMENTS, json },
|
||||||
method: 'POST',
|
|
||||||
segments: SHARES_BASE_SEGMENTS,
|
|
||||||
json: {
|
|
||||||
path,
|
|
||||||
shareType: input.kind === 'user' ? 0 : 1,
|
|
||||||
shareWith: input.shareWith,
|
|
||||||
permissions,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'create',
|
'create',
|
||||||
{ kind: input.kind },
|
sent,
|
||||||
);
|
);
|
||||||
const created = parseShareList(data, session.ncUserId).shares[0];
|
const created = parseShareList(data, session.ncUserId).shares[0];
|
||||||
if (!created) throw ncErrorDefault('nextcloudError');
|
if (!created) throw ncErrorDefault('nextcloudError');
|
||||||
@@ -339,23 +393,60 @@ export class NextcloudFilesSharesService {
|
|||||||
const session = await this.session(tenantId, userId);
|
const session = await this.session(tenantId, userId);
|
||||||
const current = await this.loadShare(tenantId, userId, session, id);
|
const current = await this.loadShare(tenantId, userId, session, id);
|
||||||
if (!current.canEdit) throw ncErrorDefault('shareRejected');
|
if (!current.canEdit) throw ncErrorDefault('shareRejected');
|
||||||
if (input.access === undefined) return current;
|
const isLink = current.kind === 'link';
|
||||||
|
// Passwort, Ablauf und Bezeichnung gibt es nur bei Links (bei anderen Arten verworfen).
|
||||||
|
const wantsLinkFields =
|
||||||
|
isLink &&
|
||||||
|
(input.password !== undefined || input.expireDate !== undefined || input.label !== undefined);
|
||||||
|
if (input.access === undefined && !wantsLinkFields) return current;
|
||||||
|
|
||||||
if (current.kind === 'link') throw ncErrorDefault('shareAccessInvalid');
|
const policy = isLink ? await this.loadPolicy(tenantId, userId, session) : null;
|
||||||
if (input.access === 'upload' || (input.access === 'edit' && !current.itemWritable)) {
|
if (policy !== null) {
|
||||||
|
if (!policy.enabled) throw ncErrorDefault('sharingDisabled');
|
||||||
|
if (!policy.links.enabled) throw ncErrorDefault('linkSharingDisabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: Record<string, unknown> = {};
|
||||||
|
const sent: SentFields = { kind: current.kind };
|
||||||
|
|
||||||
|
if (input.access !== undefined) {
|
||||||
|
if (
|
||||||
|
(input.access === 'upload' && !isLink) ||
|
||||||
|
(input.access !== 'view' && !current.itemWritable) ||
|
||||||
|
(policy !== null && input.access !== 'view' && !policy.links.uploadAllowed)
|
||||||
|
) {
|
||||||
throw ncErrorDefault('shareAccessInvalid');
|
throw ncErrorDefault('shareAccessInvalid');
|
||||||
}
|
}
|
||||||
const permissions = permissionsFor(input.access, current.itemType);
|
const permissions = permissionsFor(input.access, current.itemType);
|
||||||
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
|
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
|
||||||
if (current.access === input.access) return current;
|
if (current.access !== input.access) json.permissions = permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy !== null && wantsLinkFields) {
|
||||||
|
this.checkLinkRules(policy, input.password, input.expireDate, 'update');
|
||||||
|
// Das Passwort laesst sich nicht vergleichen: nicht leer wird gesendet, leer nur wenn eins gesetzt ist.
|
||||||
|
if (input.password) {
|
||||||
|
json.password = input.password;
|
||||||
|
sent.passwordNonEmpty = true;
|
||||||
|
} else if (input.password === '' && current.hasPassword) {
|
||||||
|
json.password = '';
|
||||||
|
}
|
||||||
|
if (input.expireDate !== undefined && input.expireDate !== (current.expiration ?? '')) {
|
||||||
|
json.expireDate = input.expireDate;
|
||||||
|
sent.expireDateSent = true;
|
||||||
|
sent.expireDateNonEmpty = input.expireDate !== '';
|
||||||
|
}
|
||||||
|
if (input.label !== undefined && input.label !== current.label) json.label = input.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(json).length === 0) return current;
|
||||||
const data = await this.run(
|
const data = await this.run(
|
||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
session,
|
session,
|
||||||
{ method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json: { permissions } },
|
{ method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json },
|
||||||
'update',
|
'update',
|
||||||
{ kind: current.kind },
|
sent,
|
||||||
);
|
);
|
||||||
const updated = parseShareList(data, session.ncUserId).shares[0];
|
const updated = parseShareList(data, session.ncUserId).shares[0];
|
||||||
if (!updated) throw ncErrorDefault('nextcloudError');
|
if (!updated) throw ncErrorDefault('nextcloudError');
|
||||||
@@ -374,4 +465,74 @@ export class NextcloudFilesSharesService {
|
|||||||
);
|
);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Uebersichten und Annehmen (D-17) --------------------------------------------------------
|
||||||
|
|
||||||
|
/** Alle eigenen Freigaben (Personen, Gruppen, Links); andere Arten nur als Zahl. */
|
||||||
|
async mine(tenantId: string, userId: string) {
|
||||||
|
const session = await this.session(tenantId, userId);
|
||||||
|
const data = await this.run(
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
session,
|
||||||
|
{ method: 'GET', segments: SHARES_BASE_SEGMENTS },
|
||||||
|
'other',
|
||||||
|
);
|
||||||
|
return parseShareList(data, session.ncUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mit mir geteilt: angenommene (`shared_with_me`) und noch offene Freigaben (`pending`).
|
||||||
|
* Nur Personen- und Gruppenfreigaben; die offene Liste fehlt auf aelteren Servern (404/405
|
||||||
|
* -> leer, kein Fehler). Eine offene Freigabe steht nie zugleich bei den angenommenen.
|
||||||
|
*/
|
||||||
|
async received(tenantId: string, userId: string) {
|
||||||
|
const session = await this.session(tenantId, userId);
|
||||||
|
const accepted = await this.run(
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
session,
|
||||||
|
{ method: 'GET', segments: SHARES_BASE_SEGMENTS, query: { shared_with_me: 'true' } },
|
||||||
|
'other',
|
||||||
|
);
|
||||||
|
const pendingResult = await ocsShareRequest(this.transport, this.gate, session, {
|
||||||
|
method: 'GET',
|
||||||
|
segments: [...SHARES_BASE_SEGMENTS, 'pending'],
|
||||||
|
});
|
||||||
|
let pendingData: unknown = [];
|
||||||
|
if (!pendingResult.ok || pendingResult.status < 200 || pendingResult.status >= 300) {
|
||||||
|
const missing =
|
||||||
|
pendingResult.ok && (pendingResult.status === 404 || pendingResult.status === 405);
|
||||||
|
if (!missing) return this.mapShareFailure(tenantId, userId, 'other', pendingResult, {});
|
||||||
|
} else {
|
||||||
|
pendingData = pendingResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = parseShareList(accepted, session.ncUserId);
|
||||||
|
const p = parseShareList(pendingData, session.ncUserId);
|
||||||
|
const people = (shares: NcShareView[]) => shares.filter((s) => s.kind !== 'link');
|
||||||
|
const pending = people(p.shares).map((s) => ({ ...s, pending: true }));
|
||||||
|
const pendingIds = new Set(pending.map((s) => s.id));
|
||||||
|
const shares = people(a.shares).filter((s) => !pendingIds.has(s.id));
|
||||||
|
const links = a.shares.length - people(a.shares).length + (p.shares.length - pending.length);
|
||||||
|
return {
|
||||||
|
shares,
|
||||||
|
pending,
|
||||||
|
hidden: a.hidden + p.hidden + links,
|
||||||
|
truncated: a.truncated || p.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async accept(tenantId: string, userId: string, id: string): Promise<{ accepted: true }> {
|
||||||
|
if (!isShareId(id)) throw ncErrorDefault('shareNotFound');
|
||||||
|
const session = await this.session(tenantId, userId);
|
||||||
|
await this.run(
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
session,
|
||||||
|
{ method: 'POST', segments: [...SHARES_BASE_SEGMENTS, 'pending', id] },
|
||||||
|
'accept',
|
||||||
|
);
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ const SHARE_HANDLERS = [
|
|||||||
'listSharesForPath',
|
'listSharesForPath',
|
||||||
'searchSharees',
|
'searchSharees',
|
||||||
'createShare',
|
'createShare',
|
||||||
|
'listMyShares',
|
||||||
|
'listReceivedShares',
|
||||||
'updateShare',
|
'updateShare',
|
||||||
'deleteShare',
|
'deleteShare',
|
||||||
|
'acceptShare',
|
||||||
];
|
];
|
||||||
const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM];
|
const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM];
|
||||||
|
|
||||||
@@ -125,6 +128,13 @@ describe('NextcloudFilesController — Metadaten', () => {
|
|||||||
expect(route('createShare')).toEqual([1, 'shares']);
|
expect(route('createShare')).toEqual([1, 'shares']);
|
||||||
expect(route('updateShare')).toEqual([2, 'shares/:id']);
|
expect(route('updateShare')).toEqual([2, 'shares/:id']);
|
||||||
expect(route('deleteShare')).toEqual([3, 'shares/:id']);
|
expect(route('deleteShare')).toEqual([3, 'shares/:id']);
|
||||||
|
expect(route('listMyShares')).toEqual([0, 'shares/mine']);
|
||||||
|
expect(route('listReceivedShares')).toEqual([0, 'shares/received']);
|
||||||
|
expect(route('acceptShare')).toEqual([1, 'shares/:id/accept']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acceptShare antwortet mit 200 statt 201', () => {
|
||||||
|
expect(Reflect.getMetadata('__httpCode__', proto.acceptShare)).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('die Teilen-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => {
|
it('die Teilen-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => {
|
||||||
@@ -136,7 +146,14 @@ describe('NextcloudFilesController — Metadaten', () => {
|
|||||||
|
|
||||||
it('die beiden :id-Handler des Teilens stehen nach allen statischen Handlern', () => {
|
it('die beiden :id-Handler des Teilens stehen nach allen statischen Handlern', () => {
|
||||||
const names = routeHandlers();
|
const names = routeHandlers();
|
||||||
for (const name of ['getSharePolicy', 'listSharesForPath', 'searchSharees', 'createShare']) {
|
for (const name of [
|
||||||
|
'getSharePolicy',
|
||||||
|
'listSharesForPath',
|
||||||
|
'searchSharees',
|
||||||
|
'createShare',
|
||||||
|
'listMyShares',
|
||||||
|
'listReceivedShares',
|
||||||
|
]) {
|
||||||
expect(names.indexOf(name), name).toBeLessThan(names.indexOf('pollFlow'));
|
expect(names.indexOf(name), name).toBeLessThan(names.indexOf('pollFlow'));
|
||||||
}
|
}
|
||||||
const staticLast = Math.max(
|
const staticLast = Math.max(
|
||||||
@@ -146,6 +163,7 @@ describe('NextcloudFilesController — Metadaten', () => {
|
|||||||
);
|
);
|
||||||
expect(names.indexOf('updateShare')).toBeGreaterThan(staticLast);
|
expect(names.indexOf('updateShare')).toBeGreaterThan(staticLast);
|
||||||
expect(names.indexOf('deleteShare')).toBeGreaterThan(staticLast);
|
expect(names.indexOf('deleteShare')).toBeGreaterThan(staticLast);
|
||||||
|
expect(names.indexOf('acceptShare')).toBeGreaterThan(staticLast);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => {
|
it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => {
|
||||||
@@ -291,6 +309,9 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
create: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
|
create: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
|
||||||
update: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
|
update: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
|
||||||
remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })),
|
remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })),
|
||||||
|
mine: vi.fn(async (..._a: unknown[]) => ({ shares: [] })),
|
||||||
|
received: vi.fn(async (..._a: unknown[]) => ({ shares: [], pending: [] })),
|
||||||
|
accept: vi.fn(async (..._a: unknown[]) => ({ accepted: true })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,12 +468,18 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
await controller.createShare(r, dto);
|
await controller.createShare(r, dto);
|
||||||
await controller.updateShare(r, '17', { access: 'view' } as any);
|
await controller.updateShare(r, '17', { access: 'view' } as any);
|
||||||
await controller.deleteShare(r, '17');
|
await controller.deleteShare(r, '17');
|
||||||
|
await controller.listMyShares(r);
|
||||||
|
await controller.listReceivedShares(r);
|
||||||
|
await controller.acceptShare(r, '18');
|
||||||
expect(shares.policy).toHaveBeenCalledWith('t1', 'u1');
|
expect(shares.policy).toHaveBeenCalledWith('t1', 'u1');
|
||||||
expect(shares.sharesForPath).toHaveBeenCalledWith('t1', 'u1', '/Projekte');
|
expect(shares.sharesForPath).toHaveBeenCalledWith('t1', 'u1', '/Projekte');
|
||||||
expect(shares.sharees).toHaveBeenCalledWith('t1', 'u1', 'ben', 'folder');
|
expect(shares.sharees).toHaveBeenCalledWith('t1', 'u1', 'ben', 'folder');
|
||||||
expect(shares.create).toHaveBeenCalledWith('t1', 'u1', dto);
|
expect(shares.create).toHaveBeenCalledWith('t1', 'u1', dto);
|
||||||
expect(shares.update).toHaveBeenCalledWith('t1', 'u1', '17', { access: 'view' });
|
expect(shares.update).toHaveBeenCalledWith('t1', 'u1', '17', { access: 'view' });
|
||||||
expect(shares.remove).toHaveBeenCalledWith('t1', 'u1', '17');
|
expect(shares.remove).toHaveBeenCalledWith('t1', 'u1', '17');
|
||||||
|
expect(shares.mine).toHaveBeenCalledWith('t1', 'u1');
|
||||||
|
expect(shares.received).toHaveBeenCalledWith('t1', 'u1');
|
||||||
|
expect(shares.accept).toHaveBeenCalledWith('t1', 'u1', '18');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Teilen-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
|
it('Teilen-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
|
||||||
@@ -469,6 +496,8 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
await expect(controller.getSharePolicy(r)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.getSharePolicy(r)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.createShare(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.createShare(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.deleteShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.deleteShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.listMyShares(r)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.acceptShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
const noTenant = userReq(undefined, 'u1');
|
const noTenant = userReq(undefined, 'u1');
|
||||||
await expect(controller.getSharePolicy(noTenant)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.getSharePolicy(noTenant)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
for (const fn of Object.values(shares)) expect(fn).not.toHaveBeenCalled();
|
for (const fn of Object.values(shares)) expect(fn).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -72,11 +72,12 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
|||||||
* DELETE connect, GET files, DELETE files, POST folders, POST move,
|
* DELETE connect, GET files, DELETE files, POST folders, POST move,
|
||||||
* GET preview, GET download, POST download/zip, POST uploads, PUT
|
* GET preview, GET download, POST download/zip, POST uploads, PUT
|
||||||
* uploads/file, GET shares/policy, GET shares/by-path, GET sharees,
|
* uploads/file, GET shares/policy, GET shares/by-path, GET sharees,
|
||||||
* POST shares (quick-261009-dkv: Teilen); danach die Parameterrouten am
|
* GET shares/mine, GET shares/received, POST shares (quick-261009-dkv:
|
||||||
* ENDE: GET/DELETE connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n,
|
* Teilen); danach die Parameterrouten am ENDE: GET/DELETE
|
||||||
* POST uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
|
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
|
||||||
* uploads/:uploadId, PUT/DELETE shares/:id. Jeder Benutzer arbeitet nur
|
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
|
||||||
* im eigenen Konto.
|
* uploads/:uploadId, PUT/DELETE shares/:id, POST shares/:id/accept.
|
||||||
|
* Jeder Benutzer arbeitet nur im eigenen Konto.
|
||||||
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
|
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
|
||||||
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
|
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
|
||||||
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
|
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
|
||||||
@@ -328,6 +329,18 @@ export class NextcloudFilesController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eigene Freigaben (Personen, Gruppen, Links). */
|
||||||
|
@Get('shares/mine')
|
||||||
|
async listMyShares(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.shares.mine(this.requireTenantId(req), this.requireUserId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mit mir geteilt, auch die noch nicht angenommenen. */
|
||||||
|
@Get('shares/received')
|
||||||
|
async listReceivedShares(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.shares.received(this.requireTenantId(req), this.requireUserId(req));
|
||||||
|
}
|
||||||
|
|
||||||
@Post('shares')
|
@Post('shares')
|
||||||
async createShare(@Req() req: AuthenticatedRequest, @Body() dto: CreateShareDto) {
|
async createShare(@Req() req: AuthenticatedRequest, @Body() dto: CreateShareDto) {
|
||||||
return this.shares.create(this.requireTenantId(req), this.requireUserId(req), dto);
|
return this.shares.create(this.requireTenantId(req), this.requireUserId(req), dto);
|
||||||
@@ -407,8 +420,15 @@ export class NextcloudFilesController {
|
|||||||
return this.shares.update(this.requireTenantId(req), this.requireUserId(req), id, dto);
|
return this.shares.update(this.requireTenantId(req), this.requireUserId(req), id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loeschen, Ablehnen und Verlassen sind fuer Nextcloud dasselbe (DELETE durch den Empfaenger). */
|
||||||
@Delete('shares/:id')
|
@Delete('shares/:id')
|
||||||
async deleteShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async deleteShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
return this.shares.remove(this.requireTenantId(req), this.requireUserId(req), id);
|
return this.shares.remove(this.requireTenantId(req), this.requireUserId(req), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('shares/:id/accept')
|
||||||
|
@HttpCode(200)
|
||||||
|
async acceptShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.shares.accept(this.requireTenantId(req), this.requireUserId(req), id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { NcSession } from './nextcloud-files.types';
|
|||||||
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
|
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
|
||||||
import {
|
import {
|
||||||
accessOf,
|
accessOf,
|
||||||
|
isRealDate,
|
||||||
OCS_OK_MAX_BYTES,
|
OCS_OK_MAX_BYTES,
|
||||||
ocsShareRequest,
|
ocsShareRequest,
|
||||||
parseShare,
|
parseShare,
|
||||||
@@ -595,3 +596,21 @@ describe('parseSharePolicy', () => {
|
|||||||
expect(policy.passwordMinLength).toBeNull();
|
expect(policy.passwordMinLength).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isRealDate', () => {
|
||||||
|
it('nimmt nur echte Kalendertage im Format JJJJ-MM-TT an', () => {
|
||||||
|
expect(isRealDate('2026-10-09')).toBe(true);
|
||||||
|
expect(isRealDate('2028-02-29')).toBe(true);
|
||||||
|
for (const bad of [
|
||||||
|
'2026-02-30',
|
||||||
|
'2027-02-29',
|
||||||
|
'2026-13-01',
|
||||||
|
'31.12.2026',
|
||||||
|
'2026-1-1',
|
||||||
|
'2026-12-31x',
|
||||||
|
'',
|
||||||
|
]) {
|
||||||
|
expect(isRealDate(bad), bad).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ function lastSegment(path: string): string {
|
|||||||
return parts.length > 0 ? parts[parts.length - 1] : '';
|
return parts.length > 0 ? parts[parts.length - 1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRealDate(value: string): boolean {
|
export function isRealDate(value: string): boolean {
|
||||||
if (!DATE_RE.test(value)) return false;
|
if (!DATE_RE.test(value)) return false;
|
||||||
const d = new Date(`${value}T00:00:00Z`);
|
const d = new Date(`${value}T00:00:00Z`);
|
||||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
||||||
|
|||||||
Reference in New Issue
Block a user