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") } }