diff --git a/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs b/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs index 777acf1903c..df5422a1bb5 100644 --- a/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs +++ b/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs @@ -11,6 +11,7 @@ using Altinn.Studio.Designer.Exceptions.CustomTemplate; using Altinn.Studio.Designer.Helpers; using Altinn.Studio.Designer.Hubs.Sync; +using Altinn.Studio.Designer.Infrastructure.ApiKeyAuth; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.RepositoryClient.Model; @@ -74,6 +75,7 @@ IBranchService branchService /// All parameters create the search parameters /// /// List of filtered repositories that user has access to. + [AllowApiKey] [HttpGet] [Route("search")] public async Task Search( diff --git a/src/Designer/backend/tests/Designer.Tests/Controllers/RepositoryController/SearchAuthMetadataTests.cs b/src/Designer/backend/tests/Designer.Tests/Controllers/RepositoryController/SearchAuthMetadataTests.cs new file mode 100644 index 00000000000..d88f343b2ae --- /dev/null +++ b/src/Designer/backend/tests/Designer.Tests/Controllers/RepositoryController/SearchAuthMetadataTests.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using Altinn.Studio.Designer.Infrastructure.ApiKeyAuth; +using Xunit; +using RepositoryControllerType = Altinn.Studio.Designer.Controllers.RepositoryController; + +namespace Designer.Tests.Controllers.RepositoryController; + +public class SearchAuthMetadataTests +{ + [Fact] + public void Search_AllowsApiKeyAuthentication() + { + MethodInfo method = typeof(RepositoryControllerType).GetMethod(nameof(RepositoryControllerType.Search)); + + Assert.NotNull(method); + Assert.NotNull(method.GetCustomAttribute()); + } +} diff --git a/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs b/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs index b8a51257cf8..48da9dcaf72 100644 --- a/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs +++ b/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs @@ -216,7 +216,7 @@ public async Task CopyRepository_TargetExistsLocally_InitialCloneMoved() ); int actualCloneCount = Directory .GetDirectories(developerClonePath) - .Count(d => Path.GetFileName(d) == targetRepositoryName); + .Count(d => Path.GetFileName(d).Equals(targetRepositoryName, StringComparison.Ordinal)); Assert.Equal(1, actualCloneCount); } finally diff --git a/src/cli/CHANGELOG.md b/src/cli/CHANGELOG.md index 1cc64987a19..85b116a039c 100644 --- a/src/cli/CHANGELOG.md +++ b/src/cli/CHANGELOG.md @@ -8,6 +8,10 @@ Section ordering: Added, Changed, Fixed, Removed, Security, Deprecated. ## [Unreleased] +### Added + +- Add `apps search` for discovering app repositories in Altinn Studio. + ## [0.1.0-preview.8] - 2026-05-13 ### Changed diff --git a/src/cli/README.md b/src/cli/README.md index 2466d877ff7..2bd3221fe7d 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -60,6 +60,7 @@ studioctl auth login --env dev --with-token < token.txt ## Core commands - `studioctl auth login`: login with Ansattporten or `--with-token` for `prod`, `dev`, `staging`, or `local` +- `studioctl apps search`: search app repositories in Altinn Studio - `studioctl app clone`: clone `org/repo` from the selected Altinn Studio environment - `studioctl app run`: run app locally - `studioctl env up`: start localtest diff --git a/src/cli/internal/cmd/app/service.go b/src/cli/internal/cmd/app/service.go index 3187e681d9f..0feaf46642f 100644 --- a/src/cli/internal/cmd/app/service.go +++ b/src/cli/internal/cmd/app/service.go @@ -66,6 +66,23 @@ type CloneResult struct { AbsPath string } +// SearchRequest contains repository search inputs. +type SearchRequest struct { + Env string + Query string + Sort string + Order string + Page int + Limit int +} + +// SearchResult contains repositories found by app search. +type SearchResult struct { + Repositories []studio.Repository + TotalCount int + TotalPages int +} + // ResolveHost resolves the configured host for an environment. func (s *Service) ResolveHost(env string) (string, error) { creds, err := auth.LoadCredentials(s.cfg.Home) @@ -114,3 +131,37 @@ func (s *Service) Clone(ctx context.Context, req CloneRequest) (CloneResult, err AbsPath: absPath, }, nil } + +// Search searches app repositories visible in the selected environment. +func (s *Service) Search(ctx context.Context, req SearchRequest) (SearchResult, error) { + creds, err := auth.LoadCredentials(s.cfg.Home) + if err != nil { + return SearchResult{}, fmt.Errorf("load credentials: %w", err) + } + + envCreds, err := creds.Get(req.Env) + if err != nil { + if errors.Is(err, auth.ErrNotLoggedIn) { + return SearchResult{}, fmt.Errorf("%w: %s", ErrNotLoggedIn, req.Env) + } + return SearchResult{}, fmt.Errorf("get credentials for %s: %w", req.Env, err) + } + + client := studio.NewClientForEnv(req.Env, s.cfg.Home, envCreds, s.cfg.Version) + result, err := client.SearchApps(ctx, studio.SearchAppsRequest{ + Query: req.Query, + Sort: req.Sort, + Order: req.Order, + Page: req.Page, + Limit: req.Limit, + }) + if err != nil { + return SearchResult{}, fmt.Errorf("search repos: %w", err) + } + + return SearchResult{ + Repositories: result.Data, + TotalCount: result.TotalCount, + TotalPages: result.TotalPages, + }, nil +} diff --git a/src/cli/internal/cmd/apps.go b/src/cli/internal/cmd/apps.go new file mode 100644 index 00000000000..8f031ee17b3 --- /dev/null +++ b/src/cli/internal/cmd/apps.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "strings" + + "altinn.studio/studioctl/internal/auth" + appsvc "altinn.studio/studioctl/internal/cmd/app" + "altinn.studio/studioctl/internal/config" + "altinn.studio/studioctl/internal/osutil" + "altinn.studio/studioctl/internal/studio" + "altinn.studio/studioctl/internal/ui" +) + +const ( + defaultAppsSearchLimit = 20 + defaultAppsSearchPage = 1 + defaultAppsSearchSort = "alpha" + defaultAppsSearchOrder = "asc" +) + +// AppsCommand implements the 'apps' command. +type AppsCommand struct { + out *ui.Output + service *appsvc.Service +} + +type appsSearchFlags struct { + env string + sort string + order string + page int + limit int + jsonOutput bool +} + +type appsSearchOutput struct { + Query string `json:"query"` + Apps []appsSearchAppOutput `json:"apps"` + TotalCount int `json:"totalCount"` + TotalPages int `json:"totalPages"` + Page int `json:"page"` + Limit int `json:"limit"` + JSONOutput bool `json:"-"` +} + +type appsSearchAppOutput struct { + AppID string `json:"appId"` + Description string `json:"description,omitempty"` + CloneURL string `json:"cloneUrl,omitempty"` + HTMLURL string `json:"htmlUrl,omitempty"` +} + +// NewAppsCommand creates a new apps command. +func NewAppsCommand(cfg *config.Config, out *ui.Output) *AppsCommand { + return &AppsCommand{ + out: out, + service: appsvc.NewService(cfg), + } +} + +// Name returns the command name. +func (c *AppsCommand) Name() string { return "apps" } + +// Synopsis returns a short description. +func (c *AppsCommand) Synopsis() string { return "Search Altinn apps" } + +// Usage returns the full help text. +func (c *AppsCommand) Usage() string { + return joinLines( + fmt.Sprintf("Usage: %s apps [options]", osutil.CurrentBin()), + "", + "Search Altinn apps.", + "", + "Subcommands:", + " search Search app repositories in Altinn Studio", + "", + fmt.Sprintf("Run '%s apps --help' for more information.", osutil.CurrentBin()), + ) +} + +// Run executes the command. +func (c *AppsCommand) Run(ctx context.Context, args []string) error { + if len(args) == 0 { + c.out.Print(c.Usage()) + return nil + } + + subCmd := args[0] + subArgs := args[1:] + + switch subCmd { + case "search": + return c.runSearch(ctx, subArgs) + case "-h", flagHelp, helpSubcmd: + c.out.Print(c.Usage()) + return nil + default: + return fmt.Errorf("%w: %s", ErrUnknownSubcommand, subCmd) + } +} + +func (c *AppsCommand) runSearch(ctx context.Context, args []string) error { + flags, query, help, err := c.parseSearchFlags(args) + if err != nil { + return err + } + if help { + c.out.Print(c.searchUsage()) + return nil + } + if query == "" { + return fmt.Errorf( + "%w: usage: %s apps search [--env ENV] ", + ErrMissingArgument, + osutil.CurrentBin(), + ) + } + + result, err := c.service.Search(ctx, appsvc.SearchRequest{ + Env: flags.env, + Query: query, + Sort: flags.sort, + Order: flags.order, + Page: flags.page, + Limit: flags.limit, + }) + if err != nil { + return mapAppsSearchError(err, flags.env) + } + + return appsSearchOutput{ + Apps: appsSearchAppsOutput(result.Repositories), + Query: query, + TotalCount: result.TotalCount, + TotalPages: result.TotalPages, + Page: flags.page, + Limit: flags.limit, + JSONOutput: flags.jsonOutput, + }.Print(c.out) +} + +func (c *AppsCommand) parseSearchFlags(args []string) (appsSearchFlags, string, bool, error) { + fs := flag.NewFlagSet("apps search", flag.ContinueOnError) + fs.SetOutput(io.Discard) + flags := appsSearchFlags{ + env: auth.DefaultEnv, + sort: defaultAppsSearchSort, + order: defaultAppsSearchOrder, + page: defaultAppsSearchPage, + limit: defaultAppsSearchLimit, + jsonOutput: false, + } + fs.StringVar(&flags.env, "env", auth.DefaultEnv, "Environment name (prod, dev, staging, local)") + fs.IntVar(&flags.limit, "limit", defaultAppsSearchLimit, "Maximum number of apps to return") + fs.IntVar(&flags.page, "page", defaultAppsSearchPage, "Result page") + fs.StringVar(&flags.sort, "sort", defaultAppsSearchSort, "Sort by alpha, created, updated, size, or id") + fs.StringVar(&flags.order, "order", defaultAppsSearchOrder, "Sort order: asc or desc") + fs.BoolVar(&flags.jsonOutput, "json", false, "Output as JSON") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return flags, "", true, nil + } + return flags, "", false, fmt.Errorf("parsing flags: %w", err) + } + if flags.limit < 1 { + return flags, "", false, fmt.Errorf("%w: --limit must be greater than 0", ErrInvalidFlagValue) + } + if flags.page < 1 { + return flags, "", false, fmt.Errorf("%w: --page must be greater than 0", ErrInvalidFlagValue) + } + if !isSupportedAppsSearchSort(flags.sort) { + return flags, "", false, fmt.Errorf( + "%w: --sort must be one of alpha, created, updated, size, or id", + ErrInvalidFlagValue, + ) + } + if !isSupportedAppsSearchOrder(flags.order) { + return flags, "", false, fmt.Errorf("%w: --order must be asc or desc", ErrInvalidFlagValue) + } + flags.sort = strings.ToLower(flags.sort) + flags.order = strings.ToLower(flags.order) + + query := strings.TrimSpace(strings.Join(fs.Args(), " ")) + return flags, query, false, nil +} + +func (c *AppsCommand) searchUsage() string { + return joinLines( + fmt.Sprintf("Usage: %s apps search [options] ", osutil.CurrentBin()), + "", + "Searches app repositories in Altinn Studio.", + "", + "Options:", + " --env ENV Environment name: prod, dev, staging, or local (default: prod)", + fmt.Sprintf(" --limit N Maximum number of apps to return (default: %d)", defaultAppsSearchLimit), + fmt.Sprintf(" --page N Result page (default: %d)", defaultAppsSearchPage), + fmt.Sprintf( + " --sort FIELD Sort by alpha, created, updated, size, or id (default: %s)", + defaultAppsSearchSort, + ), + fmt.Sprintf(" --order ORDER Sort order: asc or desc (default: %s)", defaultAppsSearchOrder), + " --json Output as JSON", + " -h, --help Show this help", + ) +} + +func mapAppsSearchError(err error, env string) error { + switch { + case errors.Is(err, appsvc.ErrNotLoggedIn): + return fmt.Errorf("%w: %s (run '%s auth login --env %s')", ErrNotLoggedIn, env, osutil.CurrentBin(), env) + case errors.Is(err, studio.ErrUnauthorized): + return fmt.Errorf("%w (run '%s auth login --env %s')", ErrInvalidToken, osutil.CurrentBin(), env) + default: + return fmt.Errorf("search failed: %w", err) + } +} + +func appsSearchAppsOutput(repositories []studio.Repository) []appsSearchAppOutput { + output := make([]appsSearchAppOutput, 0, len(repositories)) + for _, repo := range repositories { + output = append(output, appsSearchAppOutput{ + AppID: repoAppID(repo), + Description: repo.Description, + CloneURL: repo.CloneURL, + HTMLURL: repo.HTMLURL, + }) + } + return output +} + +func repoAppID(repo studio.Repository) string { + if repo.FullName != "" { + return repo.FullName + } + if repo.Owner != nil && repo.Owner.Login != "" && repo.Name != "" { + return repo.Owner.Login + "/" + repo.Name + } + return repo.Name +} + +func (o appsSearchOutput) Print(out *ui.Output) error { + if o.JSONOutput { + return printJSONOutput(out, "apps search", o) + } + if len(o.Apps) == 0 { + out.Println("No apps found.") + return nil + } + + table := ui.NewTable( + ui.NewColumn("APP ID"), + ui.NewColumn("DESCRIPTION"), + ) + for _, app := range o.Apps { + table.TextRow(app.AppID, tableDash(app.Description)) + } + out.RenderTable(table) + return nil +} + +func isSupportedAppsSearchSort(value string) bool { + switch strings.ToLower(value) { + case "alpha", "created", "updated", "size", "id": + return true + default: + return false + } +} + +func isSupportedAppsSearchOrder(value string) bool { + switch strings.ToLower(value) { + case "asc", "desc": + return true + default: + return false + } +} diff --git a/src/cli/internal/cmd/apps_internal_test.go b/src/cli/internal/cmd/apps_internal_test.go new file mode 100644 index 00000000000..6b7b2933b71 --- /dev/null +++ b/src/cli/internal/cmd/apps_internal_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + authstore "altinn.studio/studioctl/internal/auth" + "altinn.studio/studioctl/internal/config" + "altinn.studio/studioctl/internal/studio" + "altinn.studio/studioctl/internal/ui" +) + +const appsSearchTestQuery = "apps" + +func TestAppsSearchCommandPrintsResults(t *testing.T) { + t.Parallel() + + reqErrCh := make(chan string, 1) + failRequest := func(w http.ResponseWriter, format string, args ...any) { + select { + case reqErrCh <- fmt.Sprintf(format, args...): + default: + } + http.Error(w, "unexpected request", http.StatusBadRequest) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/designer/api/repos/search" { + failRequest(w, "path = %s, want /designer/api/repos/search", r.URL.Path) + return + } + query := r.URL.Query() + if got := query.Get("keyword"); got != appsSearchTestQuery { + failRequest(w, "keyword = %q, want %s", got, appsSearchTestQuery) + return + } + if got := query.Get("limit"); got != "1" { + failRequest(w, "limit = %q, want 1", got) + return + } + if got := query.Get("page"); got != "1" { + failRequest(w, "page = %q, want 1", got) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "1") + if err := json.NewEncoder(w).Encode(studio.SearchAppsResult{ + Ok: true, + TotalCount: 1, + TotalPages: 1, + Data: []studio.Repository{ + { + Name: "apps-test", + FullName: "ttd/apps-test", + Description: "Test app", + }, + }, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + defer server.Close() + + cfg := appsSearchTestConfig(t, server) + var out bytes.Buffer + command := NewAppsCommand(cfg, ui.NewOutput(&out, io.Discard, false)) + + err := command.Run( + t.Context(), + []string{"search", "--env", "dev", "--limit", "1", appsSearchTestQuery}, + ) + select { + case reqErr := <-reqErrCh: + t.Fatal(reqErr) + default: + } + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + got := out.String() + if !strings.Contains(got, "APP ID") || !strings.Contains(got, "DESCRIPTION") { + t.Fatalf("output = %q, want table headers", got) + } + if !strings.Contains(got, "ttd/apps-test") || !strings.Contains(got, "Test app") { + t.Fatalf("output = %q, want app row", got) + } +} + +func TestAppsSearchCommandPrintsJSON(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(studio.SearchAppsResult{ + Ok: true, + TotalCount: 1, + TotalPages: 1, + Data: []studio.Repository{ + { + Name: "apps-test", + FullName: "ttd/apps-test", + CloneURL: "https://altinn.studio/repos/ttd/apps-test.git", + HTMLURL: "https://altinn.studio/repos/ttd/apps-test", + }, + }, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + defer server.Close() + + cfg := appsSearchTestConfig(t, server) + var out bytes.Buffer + command := NewAppsCommand(cfg, ui.NewOutput(&out, io.Discard, false)) + + if err := command.Run(t.Context(), []string{"search", "--env", "dev", "--json", appsSearchTestQuery}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + var got appsSearchOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(out.String())), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got.Query != appsSearchTestQuery { + t.Fatalf("Query = %q, want %s", got.Query, appsSearchTestQuery) + } + if len(got.Apps) != 1 || got.Apps[0].AppID != "ttd/apps-test" { + t.Fatalf("Apps = %+v, want one ttd/apps-test app", got.Apps) + } + if got.TotalCount != 1 { + t.Fatalf("TotalCount = %d, want 1", got.TotalCount) + } +} + +func TestAppsSearchCommandRequiresQuery(t *testing.T) { + t.Parallel() + + cfg := &config.Config{} + command := NewAppsCommand(cfg, ui.NewOutput(io.Discard, io.Discard, false)) + + err := command.Run(t.Context(), []string{"search"}) + if err == nil { + t.Fatal("Run() error = nil, want missing argument") + } + if !strings.Contains(err.Error(), ErrMissingArgument.Error()) { + t.Fatalf("Run() error = %v, want missing argument", err) + } +} + +func appsSearchTestConfig(t *testing.T, server *httptest.Server) *config.Config { + t.Helper() + + cfg, err := config.New(config.Flags{Home: t.TempDir()}, "test-version") + if err != nil { + t.Fatalf("config.New() error = %v", err) + } + if err := authstore.SaveCredentials(cfg.Home, &authstore.Credentials{ + Envs: map[string]authstore.EnvCredentials{ + "dev": { + Host: strings.TrimPrefix(server.URL, "http://"), + Scheme: "http", + ApiKey: "test-api-key", + }, + }, + }); err != nil { + t.Fatalf("SaveCredentials() error = %v", err) + } + return cfg +} diff --git a/src/cli/internal/cmd/root.go b/src/cli/internal/cmd/root.go index 4f2729e43b7..d45a59ae3b2 100644 --- a/src/cli/internal/cmd/root.go +++ b/src/cli/internal/cmd/root.go @@ -63,6 +63,7 @@ func NewCLI(cfg *config.Config) *CLI { cli.Register(NewDoctorCommand(cfg, out)) cli.Register(NewSelfCommand(cfg, out)) cli.Register(NewAppCommand(cfg, out)) + cli.Register(NewAppsCommand(cfg, out)) cli.Register(NewServerCommand(cfg, out)) cli.Register(NewShellCommand(cfg, out)) cli.Register(NewAppContainersCommand(cfg, out)) @@ -124,7 +125,7 @@ func (c *CLI) printUsage() { } } - order := []string{"run", "stop", "env", "auth", "app", "install", "doctor", "self", "server", "shell"} + order := []string{"run", "stop", "env", "auth", "app", "apps", "install", "doctor", "self", "server", "shell"} for _, name := range order { if cmd, ok := c.commands[name]; ok { c.out.Printlnf(" %-*s %s", maxLen+2, name, cmd.Synopsis()) diff --git a/src/cli/internal/cmd/root_test.go b/src/cli/internal/cmd/root_test.go index 9dee7ee0588..4c21ec1193e 100644 --- a/src/cli/internal/cmd/root_test.go +++ b/src/cli/internal/cmd/root_test.go @@ -227,6 +227,11 @@ func TestCLI_Run(t *testing.T) { args: []string{"app", "stop", "--help"}, wantCode: 0, }, + { + name: "apps search command exists", + args: []string{"apps", "search", "--help"}, + wantCode: 0, + }, } for _, tt := range tests { diff --git a/src/cli/internal/studio/client.go b/src/cli/internal/studio/client.go index 3174179aa7a..e893d92652f 100644 --- a/src/cli/internal/studio/client.go +++ b/src/cli/internal/studio/client.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -75,6 +76,23 @@ type Repository struct { HTMLURL string `json:"html_url"` } +// SearchAppsRequest contains app search filters. +type SearchAppsRequest struct { + Query string + Sort string + Order string + Page int + Limit int +} + +// SearchAppsResult contains app search results and pagination metadata. +type SearchAppsResult struct { + Data []Repository `json:"data"` + Ok bool `json:"ok"` + TotalCount int `json:"totalCount"` + TotalPages int `json:"totalPages"` +} + // Client is an API client for Altinn Studio. type Client struct { env string @@ -196,6 +214,59 @@ func (c *Client) GetRepo(ctx context.Context, org, repo string) (*Repository, er return &repository, nil } +// SearchApps searches app repositories visible to the authenticated user through the Designer API. +func (c *Client) SearchApps(ctx context.Context, search SearchAppsRequest) (*SearchAppsResult, error) { + endpoint := fmt.Sprintf("%s://%s/designer/api/repos/search", c.scheme, c.host) + query := url.Values{} + if search.Query != "" { + query.Set("keyword", search.Query) + } + if search.Sort != "" { + query.Set("sortBy", search.Sort) + } + if search.Order != "" { + query.Set("order", search.Order) + } + if search.Page > 0 { + query.Set("page", strconv.Itoa(search.Page)) + } + if search.Limit > 0 { + query.Set("limit", strconv.Itoa(search.Limit)) + } + if encoded := query.Encode(); encoded != "" { + endpoint += "?" + encoded + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setRequestHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer closeResponseBody(resp.Body) + + if resp.StatusCode == http.StatusUnauthorized { + return nil, ErrUnauthorized + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck // Best effort read for error message + return nil, fmt.Errorf("%w %d: %s", ErrUnexpectedStatus, resp.StatusCode, string(body)) + } + + var result SearchAppsResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &result, nil +} + // CloneRepo clones a repository to the specified destination. func (c *Client) CloneRepo(ctx context.Context, org, repo, destPath string) error { absPath, err := filepath.Abs(destPath) diff --git a/src/cli/internal/studio/client_test.go b/src/cli/internal/studio/client_test.go index 1a0f4802ea8..3006a85b7d1 100644 --- a/src/cli/internal/studio/client_test.go +++ b/src/cli/internal/studio/client_test.go @@ -164,6 +164,119 @@ func TestClient_GetRepo_NotFound(t *testing.T) { } } +func TestClient_SearchApps_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(searchAppsSuccessHandler(t)) + defer server.Close() + + host := server.URL[7:] + client := &Client{ + host: host, + apiKey: "test-api-key", + version: config.NewVersion("test-version"), + scheme: "http", + httpClient: server.Client(), + } + + result, err := client.SearchApps(context.Background(), SearchAppsRequest{ + Query: "apps", + Sort: "updated", + Order: "desc", + Page: 2, + Limit: 10, + }) + if err != nil { + t.Fatalf("SearchApps failed: %v", err) + } + if result.TotalCount != 27 { + t.Fatalf("TotalCount = %d, want 27", result.TotalCount) + } + if result.TotalPages != 3 { + t.Fatalf("TotalPages = %d, want 3", result.TotalPages) + } + if len(result.Data) != 1 || result.Data[0].FullName != "ttd/apps-test" { + t.Fatalf("Data = %+v, want one ttd/apps-test repo", result.Data) + } +} + +func searchAppsSuccessHandler(t *testing.T) http.HandlerFunc { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + assertSearchAppsRequest(t, r) + + result := SearchAppsResult{ + Ok: true, + TotalCount: 27, + TotalPages: 3, + Data: []Repository{ + { + ID: 1, + Name: "apps-test", + FullName: "ttd/apps-test", + Description: "Test app", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + t.Errorf("encode response: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } +} + +func assertSearchAppsRequest(t *testing.T, r *http.Request) { + t.Helper() + + if apiKey := r.Header.Get("X-Api-Key"); apiKey != "test-api-key" { + t.Errorf("expected X-Api-Key header, got %q", apiKey) + } + if userAgent := r.Header.Get("User-Agent"); userAgent != "studioctl/test-version" { + t.Errorf("expected User-Agent 'studioctl/test-version', got %q", userAgent) + } + if r.URL.Path != "/designer/api/repos/search" { + t.Errorf("expected path /designer/api/repos/search, got %s", r.URL.Path) + } + + query := r.URL.Query() + wantQuery := map[string]string{ + "keyword": "apps", + "limit": "10", + "order": "desc", + "page": "2", + "sortBy": "updated", + } + for key, want := range wantQuery { + if got := query.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } +} + +func TestClient_SearchApps_Unauthorized(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + host := server.URL[7:] + client := &Client{ + host: host, + apiKey: "bad-api-key", + version: config.NewVersion("test-version"), + scheme: "http", + httpClient: server.Client(), + } + + _, err := client.SearchApps(context.Background(), SearchAppsRequest{Query: "apps"}) + if !errors.Is(err, ErrUnauthorized) { + t.Errorf("expected ErrUnauthorized, got %v", err) + } +} + func TestClient_buildCloneURL_DoesNotEmbedCredentials(t *testing.T) { t.Parallel() client := &Client{