package kiosk import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "git.golem15.com/golem15/summercms/modules/compass" ) // registryServer serves handler under RegistryAPIPath and records the // request URIs it saw. func registryServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *[]string) { t.Helper() var mu sync.Mutex var seen []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() seen = append(seen, r.URL.RequestURI()) mu.Unlock() handler(w, r) })) t.Cleanup(srv.Close) return srv, &seen } func newTestClient(t *testing.T, base string) *Client { t.Helper() c, err := NewClient(base, nil) if err != nil { t.Fatalf("NewClient(%s): %v", base, err) } return c } // TestRegistryClient covers decoding, query escaping, the error mapping // and the transport limits of kiosk.Client. func TestRegistryClient(t *testing.T) { ctx := context.Background() t.Run("decode", func(t *testing.T) { srv, seen := registryServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Accept") != "application/json" { t.Errorf("Accept = %q", r.Header.Get("Accept")) } switch r.URL.Path { case RegistryAPIPath + "/plugins": _ = json.NewEncoder(w).Encode(ListResponse{Data: []RegistryPlugin{{ID: "acme.shop", LatestVersion: "v0.2.0"}}, Meta: ListMeta{Total: 1}}) case RegistryAPIPath + "/plugins/acme.shop": _ = json.NewEncoder(w).Encode(DetailResponse{Data: RegistryPluginDetail{RegistryPlugin: RegistryPlugin{ID: "acme.shop"}, Requires: Requires{"acme.user": "v0.1.0"}}}) case RegistryAPIPath + "/plugins/acme.shop/versions": _, _ = w.Write([]byte(`{"data":[{"version":"v0.2.0","framework":"v0.6.0","requires":{},"future":true},{"version":"v0.1.0","framework":"v0.5.0","requires":{"acme.user":""}}]}`)) default: http.NotFound(w, r) } }) c := newTestClient(t, srv.URL+"/") if c.BaseURL() != srv.URL { t.Errorf("BaseURL = %q, want the trailing slash trimmed", c.BaseURL()) } list, err := c.List(ctx, "", "") if err != nil || len(list.Data) != 1 || list.Data[0].ID != "acme.shop" || list.Meta.Total != 1 { t.Fatalf("List = %+v, %v", list, err) } detail, err := c.Plugin(ctx, "acme.shop") if err != nil || detail.ID != "acme.shop" || detail.Requires["acme.user"] != "v0.1.0" { t.Fatalf("Plugin = %+v, %v", detail, err) } versions, err := c.Versions(ctx, "acme.shop") if err != nil || len(versions) != 2 || versions[0].Version != "v0.2.0" || versions[1].Framework != "v0.5.0" { t.Fatalf("Versions = %+v, %v", versions, err) } if (*seen)[0] != RegistryAPIPath+"/plugins" { t.Errorf("List without filters asked %q", (*seen)[0]) } }) t.Run("query escaping", func(t *testing.T) { var got []string srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { got = append(got, r.URL.Path+"|"+r.URL.Query().Get("q")+"|"+r.URL.Query().Get("category")+"|"+r.URL.RawQuery) _, _ = w.Write([]byte(`{"data":[],"meta":{"total":0}}`)) }) c := newTestClient(t, srv.URL) if _, err := c.List(ctx, "100% a&b=c #x", "content"); err != nil { t.Fatal(err) } if _, err := c.List(ctx, "", "seo"); err != nil { t.Fatal(err) } if got[0] != RegistryAPIPath+"/plugins|100% a&b=c #x|content|category=content&q=100%25+a%26b%3Dc+%23x" { t.Errorf("search request = %q", got[0]) } if got[1] != RegistryAPIPath+"/plugins||seo|category=seo" { t.Errorf("category request = %q", got[1]) } }) t.Run("plugin id is path-escaped", func(t *testing.T) { srv, seen := registryServer(t, func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }) c := newTestClient(t, srv.URL) _, _ = c.Plugin(ctx, "a/../b?x") if len(*seen) != 1 || (*seen)[0] != RegistryAPIPath+"/plugins/a%2F..%2Fb%3Fx" { t.Errorf("requested %q", *seen) } }) t.Run("not found", func(t *testing.T) { srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no plugin"}}`)) }) c := newTestClient(t, srv.URL) _, err := c.Plugin(ctx, "acme.gone") if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "acme.gone") || !strings.Contains(err.Error(), srv.URL) { t.Errorf("Plugin 404 = %v, want ErrNotFound naming the ID and the registry", err) } _, err = c.Versions(ctx, "acme.gone") if !errors.Is(err, ErrNotFound) { t.Errorf("Versions 404 = %v", err) } _, err = c.List(ctx, "", "") if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), srv.URL) { t.Errorf("List 404 = %v", err) } }) t.Run("error envelope", func(t *testing.T) { srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("q") == "plain" { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("bad gateway")) return } w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"code":"invalid_query","message":"category must match ^[a-z0-9-]{1,40}$"}}`)) }) c := newTestClient(t, srv.URL) _, err := c.List(ctx, "", "Bad") if err == nil || errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "HTTP 400 invalid_query") || !strings.Contains(err.Error(), "category must match") { t.Errorf("400 = %v, want the code and message", err) } _, err = c.List(ctx, "plain", "") if err == nil || !strings.Contains(err.Error(), "HTTP 502") || strings.Contains(err.Error(), "html") { t.Errorf("502 without an envelope = %v", err) } }) t.Run("server error with envelope", func(t *testing.T) { srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal","message":"database down"}}`)) }) _, err := newTestClient(t, srv.URL).Versions(ctx, "acme.shop") if err == nil || !strings.Contains(err.Error(), "HTTP 500 internal: database down") { t.Errorf("500 = %v", err) } }) t.Run("bad json", func(t *testing.T) { srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":[`)) }) _, err := newTestClient(t, srv.URL).Versions(ctx, "acme.shop") if err == nil || !strings.Contains(err.Error(), "decode") { t.Errorf("truncated JSON = %v", err) } }) t.Run("oversized body", func(t *testing.T) { srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":[],"meta":{"total":0},"pad":"`)) _, _ = w.Write([]byte(strings.Repeat("x", registryMaxBytes))) _, _ = w.Write([]byte(`"}`)) }) if _, err := newTestClient(t, srv.URL).List(ctx, "", ""); err == nil { t.Error("a body over the cap was accepted") } }) t.Run("redirect is not followed", func(t *testing.T) { target, targetSeen := registryServer(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":[],"meta":{"total":0}}`)) }) srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL+r.URL.RequestURI(), http.StatusFound) }) _, err := newTestClient(t, srv.URL).List(ctx, "", "") if err == nil || !strings.Contains(err.Error(), "redirect") || !strings.Contains(err.Error(), "plugins.registry_url") { t.Errorf("redirect = %v, want an error naming plugins.registry_url", err) } if len(*targetSeen) != 0 { t.Errorf("the redirect target was called: %v", *targetSeen) } }) t.Run("NewClient", func(t *testing.T) { for _, base := range []string{"", " ", "/"} { if _, err := NewClient(base, nil); !errors.Is(err, ErrRegistryDisabled) { t.Errorf("NewClient(%q) = %v, want ErrRegistryDisabled", base, err) } } for base, want := range map[string]string{ "ftp://example.test": "https://", "example.test": "https://", "https://": "no host", "https://user:pw@example.test": "user info", "https://example.test/?a=b": "query", "https://example.test/#x": "query", "https://example.test/?": "query", "https://exa mple.test": "registry URL", } { if _, err := NewClient(base, nil); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("NewClient(%q) = %v, want an error naming %q", base, err, want) } } c, err := NewClient(" https://registry.example.test/ ", nil) if err != nil || c.BaseURL() != "https://registry.example.test" { t.Errorf("NewClient trims: %v, %v", c, err) } }) t.Run("RegistryURL", func(t *testing.T) { if got := RegistryURL(nil); got != DefaultRegistryURL { t.Errorf("RegistryURL(nil) = %q", got) } cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development"}}) if err != nil { t.Fatal(err) } if got := RegistryURL(cfg); got != DefaultRegistryURL { t.Errorf("RegistryURL without the key = %q", got) } for set, want := range map[string]string{ "https://registry.example.test/": "https://registry.example.test", " http://127.0.0.1:8080 ": "http://127.0.0.1:8080", "": "", } { if err := cfg.Set("plugins.registry_url", set); err != nil { t.Fatal(err) } if got := RegistryURL(cfg); got != want { t.Errorf("RegistryURL(%q) = %q, want %q", set, got, want) } } env, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_PLUGINS__REGISTRY_URL=https://env.example.test"}}) if err != nil { t.Fatal(err) } if got := RegistryURL(env); got != "https://env.example.test" { t.Errorf("RegistryURL from the environment = %q", got) } }) }