package main import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) func TestConfigRoundTripProtectsToken(t *testing.T) { configFile := filepath.Join(t.TempDir(), "nested", "config.json") want := Config{ BaseURL: "https://shop.example", AccessToken: "secret-token", TokenType: "Bearer", ExpiresAt: time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC), } if err := saveConfig(configFile, want); err != nil { t.Fatalf("saveConfig: %v", err) } got, err := loadConfig(configFile) if err != nil { t.Fatalf("loadConfig: %v", err) } if got.AccessToken != want.AccessToken || !got.ExpiresAt.Equal(want.ExpiresAt) || got.BaseURL != want.BaseURL { t.Fatalf("round trip = %+v, want %+v", got, want) } info, err := os.Stat(configFile) if err != nil { t.Fatalf("stat config: %v", err) } if mode := info.Mode().Perm(); mode != 0o600 { t.Fatalf("config mode = %o, want 600", mode) } } func TestConfigPathHonorsXDGConfigHome(t *testing.T) { xdg := t.TempDir() t.Setenv("CMSCTL_CONFIG", "") t.Setenv("XDG_CONFIG_HOME", xdg) got, err := configPath("") if err != nil { t.Fatalf("configPath: %v", err) } want := filepath.Join(xdg, "cmsctl", "config.json") if got != want { t.Fatalf("configPath = %q, want %q", got, want) } } func TestDeviceLoginPersistsTokenAndBaseURL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/auth/device": _, _ = io.WriteString(w, `{"device_code":"device-1","user_code":"ABCD-EFGH","verification_uri_complete":"http://example/device?code=ABCD-EFGH","expires_in":10,"interval":1}`) case "/api/auth/device/token": _, _ = io.WriteString(w, `{"access_token":"persisted-token","token_type":"Bearer","expires_in":3600}`) default: http.NotFound(w, r) } })) defer server.Close() configFile := filepath.Join(t.TempDir(), "config.json") var stdout, stderr bytes.Buffer err := runWithStdin( []string{"--config", configFile, "--base-url", server.URL, "auth", "login", "--scope", "read"}, strings.NewReader(""), &stdout, &stderr, ) if err != nil { t.Fatalf("login: %v (stderr=%s)", err, stderr.String()) } cfg, err := loadConfig(configFile) if err != nil { t.Fatalf("load persisted config: %v", err) } if cfg.AccessToken != "persisted-token" { t.Fatalf("access token = %q", cfg.AccessToken) } if cfg.BaseURL != server.URL { t.Fatalf("base URL = %q, want %q", cfg.BaseURL, server.URL) } } func TestClientSendsBearerToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/nodes" { t.Errorf("path = %q, want /api/nodes", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer token-123" { t.Errorf("authorization = %q", got) } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"items":[],"total":0,"offset":0,"limit":50}`) })) defer server.Close() client := newClient(Config{BaseURL: server.URL, TokenType: "Bearer"}, "token-123", "", time.Second) body, err := client.get(context.Background(), client.cmsURL("/nodes")) if err != nil { t.Fatalf("get: %v", err) } if !json.Valid(body) { t.Fatalf("response is not JSON: %s", body) } } func TestFinderImportUsesBackofficeBaseURL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/catalog/admin/items/batch" { t.Errorf("finder import path = %q, want /catalog/admin/items/batch", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"status":"ok","inserts":1}`) })) defer server.Close() client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "device-token", "", time.Second) var stdout bytes.Buffer if err := runFinder(context.Background(), client, []string{"import", "-"}, strings.NewReader(`{"id":1,"title":"Piped"}`), &stdout); err != nil { t.Fatalf("finder import: %v", err) } if !strings.Contains(stdout.String(), `"inserts":1`) { t.Fatalf("stdout = %q", stdout.String()) } } func TestRunCMSCreateFromStdin(t *testing.T) { var gotAuth, gotBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") data, _ := io.ReadAll(r.Body) gotBody = string(data) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"id":"node-1","dataType":"page","data":{"title":"Piped"}}`) })) defer server.Close() configFile := filepath.Join(t.TempDir(), "config.json") var stdout, stderr bytes.Buffer err := runWithStdin( []string{"--config", configFile, "--base-url", server.URL, "--token", "device-token", "cms", "nodes", "create", "--input", "-"}, strings.NewReader(`{"dataType":"page","data":{"title":"Piped"}}`), &stdout, &stderr, ) if err != nil { t.Fatalf("run: %v (stderr=%s)", err, stderr.String()) } if gotAuth != "Bearer device-token" { t.Fatalf("authorization = %q", gotAuth) } if gotBody != `{"dataType":"page","data":{"title":"Piped"}}` { t.Fatalf("request body = %q", gotBody) } if !strings.Contains(stdout.String(), `"node-1"`) { t.Fatalf("stdout = %q", stdout.String()) } } func TestScanJSONLinesAcceptsArrayAndJSONL(t *testing.T) { for _, input := range []string{ `[{"id":1},{"id":2}]`, "{\"id\":1}\n{\"id\":2}\n", } { items, err := scanJSONLines([]byte(input)) if err != nil { t.Fatalf("scanJSONLines(%q): %v", input, err) } if len(items) != 2 { t.Fatalf("scanJSONLines(%q) = %d items, want 2", input, len(items)) } } } func TestCMSPatchAndEdgeCreateUseNativeEndpoints(t *testing.T) { var methods, paths []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { methods = append(methods, r.Method) paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodPost { _, _ = io.WriteString(w, `{"id":"edge-1"}`) return } _, _ = io.WriteString(w, `{"id":"node-1","data":{"title":"merged"}}`) })) defer server.Close() client := newClient(Config{BaseURL: server.URL}, "token", "", time.Second) var output bytes.Buffer if err := runCMSNodes(context.Background(), client, []string{"patch", "node-1", "--input", "-"}, strings.NewReader(`{"data":{"title":"merged"}}`), &output); err != nil { t.Fatalf("patch: %v", err) } if err := runCMSEdges(context.Background(), client, []string{"create", "--from", "page-1", "--to", "node-1"}, strings.NewReader(""), &output); err != nil { t.Fatalf("edge create: %v", err) } if strings.Join(methods, ",") != "PATCH,POST" { t.Fatalf("methods = %v", methods) } if strings.Join(paths, ",") != "/api/nodes/node-1,/api/edges" { t.Fatalf("paths = %v", paths) } } func TestFinderSearchPassesFiltersAndStreamItemsUsesPipe(t *testing.T) { var searchRequest map[string]any var streamBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/find/api/stream": if err := json.NewDecoder(r.Body).Decode(&searchRequest); err != nil { t.Errorf("decode search request: %v", err) } _, _ = io.WriteString(w, `{"totalHits":1}`) case "/find/api/stream-items": data, _ := io.ReadAll(r.Body) streamBody = string(data) _, _ = io.WriteString(w, `{"id":42}`) default: http.NotFound(w, r) } })) defer server.Close() client := newClient(Config{BaseURL: server.URL, FinderURL: server.URL + "/find"}, "token", "", time.Second) var output bytes.Buffer if err := runFinder(context.Background(), client, []string{"search", "--q", "drill", "--filters", `{"string":[{"id":"brand","value":["Dala"]}]}`, "--stock", "se", "--disable-grouping"}, strings.NewReader(""), &output); err != nil { t.Fatalf("finder search: %v", err) } if searchRequest["query"] != "drill" || searchRequest["disableGrouping"] != true { t.Fatalf("search request = %#v", searchRequest) } if _, ok := searchRequest["string"]; !ok { t.Fatalf("search request omitted string filters: %#v", searchRequest) } if err := runFinder(context.Background(), client, []string{"stream-items", "-"}, strings.NewReader("41\n42\n"), &output); err != nil { t.Fatalf("stream items: %v", err) } if streamBody != "41\n42\n" { t.Fatalf("stream body = %q", streamBody) } } func TestCMSReadCommandsUseBackofficeBaseURL(t *testing.T) { var paths []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{}`) })) defer server.Close() client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "token", "", time.Second) var output bytes.Buffer if err := runCMSSchemas(context.Background(), client, []string{"get", "page"}, strings.NewReader(""), &output); err != nil { t.Fatalf("schema get: %v", err) } if err := runCMSTree(context.Background(), client, []string{"root", "--depth", "2"}, &output); err != nil { t.Fatalf("tree: %v", err) } if strings.Join(paths, ",") != "/api/schemas/page,/api/tree/root" { t.Fatalf("paths = %v", paths) } } func TestFinderAdminAlwaysUsesBackofficeBaseURL(t *testing.T) { var methods, paths []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { methods = append(methods, r.Method) paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"status":"ok"}`) })) defer server.Close() client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "token", "", time.Second) var output bytes.Buffer if err := runFinder(context.Background(), client, []string{"admin", "settings", "get"}, strings.NewReader(""), &output); err != nil { t.Fatalf("admin settings: %v", err) } if err := runFinder(context.Background(), client, []string{"admin", "facets", "delete", "brand", "--yes"}, strings.NewReader(""), &output); err != nil { t.Fatalf("admin facet delete: %v", err) } if strings.Join(methods, ",") != "GET,DELETE" || strings.Join(paths, ",") != "/catalog/admin/settings,/catalog/admin/facets/brand" { t.Fatalf("requests = %v %v", methods, paths) } }