Batch archive/restore/delete/approve/private validate every UID against the PhotoPrism DB in one query. Per-photo PUT/approve/like/stack-file ops are ownership-checked. Admin-role sessions pass through fully so settings/users/index dialogs keep working. Paths are unescaped+cleaned before classification so encoded dot-segments can't smuggle past the allowlist. Full httptest coverage of the routing decisions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func TestScopeQ(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
q string
|
|
base string
|
|
want string
|
|
}{
|
|
{"empty q gains scope", "", "dtoro", `path:"dtoro/*"`},
|
|
{"plain search gains scope", "label:dog", "dtoro", `label:dog path:"dtoro/*"`},
|
|
{"inside path kept", `path:"dtoro/2024/*" label:dog`, "dtoro", `path:"dtoro/2024/*" label:dog`},
|
|
{"exact base kept", `path:"dtoro"`, "dtoro", `path:"dtoro"`},
|
|
{"outside path replaced", `path:"muli/*"`, "dtoro", `path:"dtoro/*"`},
|
|
{"bare term outside replaced", `path:muli/x label:dog`, "dtoro", `label:dog path:"dtoro/*"`},
|
|
{"pipe alternative escaping", `path:"dtoro/*|muli/*"`, "dtoro", `path:"dtoro/*"`},
|
|
{"pipe all inside kept", `path:"dtoro/a|dtoro/b/*"`, "dtoro", `path:"dtoro/a|dtoro/b/*"`},
|
|
{"sibling prefix rejected", `path:"dtoro2/*"`, "dtoro", `path:"dtoro/*"`},
|
|
{"bare wildcard on base rejected", `path:dtoro*`, "dtoro", `path:"dtoro/*"`},
|
|
{"mixed valid+invalid terms collapse", `path:"dtoro/a" path:"muli/b"`, "dtoro", `path:"dtoro/*"`},
|
|
{"case-insensitive filter name", `PATH:"muli/*"`, "dtoro", `path:"dtoro/*"`},
|
|
{"nested base", `path:"family/alice/x"`, "family/alice", `path:"family/alice/x"`},
|
|
{"nested base parent escape", `path:"family/*"`, "family/alice", `path:"family/alice/*"`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := scopeQ(tc.q, tc.base); got != tc.want {
|
|
t.Errorf("scopeQ(%q, %q) = %q, want %q", tc.q, tc.base, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestScopeSearchValues(t *testing.T) {
|
|
v := url.Values{}
|
|
v.Set("count", "60")
|
|
v.Set("path", "muli/*")
|
|
got := scopeSearchValues(v, "dtoro")
|
|
if got.Get("path") != "" {
|
|
t.Errorf("outside path param should be dropped, got %q", got.Get("path"))
|
|
}
|
|
if got.Get("q") != `path:"dtoro/*"` {
|
|
t.Errorf("q should carry the scope, got %q", got.Get("q"))
|
|
}
|
|
if got.Get("count") != "60" {
|
|
t.Errorf("unrelated params must survive, got count=%q", got.Get("count"))
|
|
}
|
|
|
|
v2 := url.Values{}
|
|
v2.Set("path", "dtoro/2024")
|
|
got2 := scopeSearchValues(v2, "dtoro")
|
|
if got2.Get("path") != "dtoro/2024" {
|
|
t.Errorf("inside path param should be kept, got %q", got2.Get("path"))
|
|
}
|
|
}
|
|
|
|
// fakePP stands in for PhotoPrism: answers /session per token, echoes
|
|
// every other request's method+path+query back as JSON.
|
|
func fakePP(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/v1/session" && r.Method == http.MethodGet {
|
|
var user map[string]any
|
|
switch r.Header.Get("X-Auth-Token") {
|
|
case "tok-scoped":
|
|
user = map[string]any{"UID": "u1", "Name": "alice", "Role": "user", "BasePath": "alice"}
|
|
case "tok-admin":
|
|
user = map[string]any{"UID": "u0", "Name": "root", "Role": "admin", "BasePath": "alice"}
|
|
default:
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"user": user})
|
|
return
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/v1/photos/") && r.Method == http.MethodGet &&
|
|
strings.Count(r.URL.Path, "/") == 4 {
|
|
uid := strings.TrimPrefix(r.URL.Path, "/api/v1/photos/")
|
|
path := "alice/2024"
|
|
if strings.HasPrefix(uid, "foreign") {
|
|
path = "bob/2024"
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"Path": path})
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"echo": r.Method + " " + r.URL.Path,
|
|
"query": r.URL.RawQuery,
|
|
"handled": true,
|
|
})
|
|
}))
|
|
}
|
|
|
|
func proxyRig(t *testing.T) (*httptest.Server, func()) {
|
|
t.Helper()
|
|
up := fakePP(t)
|
|
cfg := &Config{PhotoprismBaseURL: up.URL}
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.UseRawPath = true
|
|
r.UnescapePathValues = false
|
|
r.Any("/api/v1/*rest", handlePPProxy(cfg, nil))
|
|
front := httptest.NewServer(r)
|
|
return front, func() { front.Close(); up.Close() }
|
|
}
|
|
|
|
func proxyReq(t *testing.T, front, method, path, token string) (int, string) {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(method, front+path, nil)
|
|
if token != "" {
|
|
req.Header.Set("X-Auth-Token", token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var sb strings.Builder
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := resp.Body.Read(buf)
|
|
sb.Write(buf[:n])
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return resp.StatusCode, sb.String()
|
|
}
|
|
|
|
func TestProxyRouting(t *testing.T) {
|
|
front, done := proxyRig(t)
|
|
defer done()
|
|
|
|
t.Run("media passes unauthenticated", func(t *testing.T) {
|
|
code, body := proxyReq(t, front.URL, "GET", "/api/v1/t/abc/tok/tile_224", "")
|
|
if code != 200 || !strings.Contains(body, "/api/v1/t/abc/tok/tile_224") {
|
|
t.Errorf("thumb should pass through, got %d %s", code, body)
|
|
}
|
|
})
|
|
t.Run("scoped search gains path scope", func(t *testing.T) {
|
|
code, body := proxyReq(t, front.URL, "GET", "/api/v1/photos?count=3&q="+url.QueryEscape(`path:"bob/*"`), "tok-scoped")
|
|
if code != 200 {
|
|
t.Fatalf("got %d", code)
|
|
}
|
|
var out struct{ Query string }
|
|
json.Unmarshal([]byte(body), &out)
|
|
q, _ := url.ParseQuery(out.Query)
|
|
if q.Get("q") != `path:"alice/*"` {
|
|
t.Errorf("escaping q must collapse to own scope, got %q", q.Get("q"))
|
|
}
|
|
})
|
|
t.Run("scoped settings blocked", func(t *testing.T) {
|
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-scoped")
|
|
if code != http.StatusForbidden {
|
|
t.Errorf("settings should 403 for scoped user, got %d", code)
|
|
}
|
|
})
|
|
t.Run("admin settings passes", func(t *testing.T) {
|
|
code, body := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-admin")
|
|
if code != 200 || !strings.Contains(body, "/api/v1/settings") {
|
|
t.Errorf("admin should pass through, got %d %s", code, body)
|
|
}
|
|
})
|
|
t.Run("no token unauthorized", func(t *testing.T) {
|
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos", "")
|
|
if code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", code)
|
|
}
|
|
})
|
|
t.Run("own photo readable, foreign 404", func(t *testing.T) {
|
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos/mine123", "tok-scoped")
|
|
if code != 200 {
|
|
t.Errorf("own photo should pass, got %d", code)
|
|
}
|
|
code, _ = proxyReq(t, front.URL, "GET", "/api/v1/photos/foreign9", "tok-scoped")
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("foreign photo should 404, got %d", code)
|
|
}
|
|
})
|
|
t.Run("encoded traversal cannot reach settings as media", func(t *testing.T) {
|
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/t%2F..%2Fsettings", "tok-scoped")
|
|
if code != http.StatusForbidden {
|
|
t.Errorf("traversal should classify as settings and 403, got %d", code)
|
|
}
|
|
})
|
|
t.Run("batch without db fails closed", func(t *testing.T) {
|
|
req, _ := http.NewRequest("POST", front.URL+"/api/v1/batch/photos/archive",
|
|
strings.NewReader(`{"photos":["p1"]}`))
|
|
req.Header.Set("X-Auth-Token", "tok-scoped")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("batch with nil ppDb must 403, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPathValueAllowed(t *testing.T) {
|
|
if pathValueAllowed(`"muli/*"`, "dtoro") {
|
|
t.Error("outside value must be rejected")
|
|
}
|
|
if !pathValueAllowed(`"dtoro/Photos/2024"`, "dtoro") {
|
|
t.Error("inside value must be allowed")
|
|
}
|
|
if pathValueAllowed("dtoro/a|muli/b", "dtoro") {
|
|
t.Error("any escaping pipe alternative must reject the whole value")
|
|
}
|
|
}
|