package setting
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"gitcode.com/gitcode-cli/cli/api"
cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil"
"gitcode.com/gitcode-cli/cli/pkg/iostreams"
"gitcode.com/gitcode-cli/cli/pkg/testutil"
)
func TestSettingRunSingleRepositoryReadsBeforeConfirmingAndPreservesFields(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, errOut := iostreams.TestTTY()
_, _ = in.WriteString(confirmationPhrase("enable", "owner/repo") + "\n")
var putBody map[string]any
var requests []string
var v5Auth, settingAuth []string
clientFactory := func() (*http.Client, error) {
return testutil.NewTestHTTPClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
v5Auth = append(v5Auth, r.Header.Get("Authorization"))
writeJSON(w, `{"id":"project-1","full_name":"owner/repo"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
settingAuth = append(settingAuth, r.Header.Get("Authorization"))
writeJSON(w, `{"data":{"action_enabled":false,"block_all_new_pipelines":true,"block_cross_repo_pr_triggers":false}}`)
case r.Method == http.MethodPut && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
settingAuth = append(settingAuth, r.Header.Get("Authorization"))
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read PUT body: %v", err)
return
}
if err := json.Unmarshal(body, &putBody); err != nil {
t.Errorf("decode PUT body: %v", err)
}
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})), nil
}
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
readPassword: hiddenJWTReader(t, jwt),
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
if strings.Join(requests, ",") != "GET /api/v5/repos/owner/repo,GET /api/v2/projects/project-1/actions/setting,PUT /api/v2/projects/project-1/actions/setting" {
t.Fatalf("requests = %v, want repository GET, setting GET, then PUT", requests)
}
if len(v5Auth) != 1 || v5Auth[0] != "Bearer test-token" {
t.Fatalf("v5 authorization = %v, want the classic token", v5Auth)
}
if len(settingAuth) != 2 || settingAuth[0] != "Bearer "+jwt || settingAuth[1] != "Bearer "+jwt {
t.Fatalf("setting authorization = %v, want the web JWT on GET and PUT", settingAuth)
}
if putBody["action_enabled"] != true || putBody["block_all_new_pipelines"] != true || putBody["block_cross_repo_pr_triggers"] != false {
t.Fatalf("PUT body = %#v, want only action_enabled changed", putBody)
}
if _, ok := putBody["project_id"]; ok {
t.Fatalf("PUT body unexpectedly contains project_id: %#v", putBody)
}
if !strings.Contains(errOut.String(), "! Warning: This command calls a GitCode Web API endpoint") || !strings.Contains(errOut.String(), "! Type \"enable Actions for owner/repo\" to confirm:") {
t.Fatalf("confirmation prompt = %q", errOut.String())
}
if !strings.Contains(out.String(), "Actions enabled for 1/1 repositories") {
t.Fatalf("output = %q", out.String())
}
}
func TestSettingRunPromptsForWebJWTInInteractiveMode(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, errOut := iostreams.TestTTY()
_, _ = in.WriteString(confirmationPhrase("enable", "owner/repo") + "\n")
passwordRead := false
var settingAuth []string
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
settingAuth = append(settingAuth, r.Header.Get("Authorization"))
writeJSON(w, `{"action_enabled":false}`)
case r.Method == http.MethodPut:
settingAuth = append(settingAuth, r.Header.Get("Authorization"))
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
readPassword: func(fd int) ([]byte, error) {
passwordRead = true
if fd != -1 {
t.Fatalf("test password reader fd = %d, want test sentinel", fd)
}
return []byte(jwt), nil
},
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
if !strings.Contains(errOut.String(), "top-right corner") || !strings.Contains(errOut.String(), "Console") || !strings.Contains(errOut.String(), "copy(localStorage.getItem('access_token'))") || !strings.Contains(errOut.String(), "Web session JWT (input hidden; press Enter when complete):") {
t.Fatalf("prompt output = %q, want JWT guidance and paste prompt", errOut.String())
}
if !passwordRead {
t.Fatal("interactive JWT input did not use the hidden password reader")
}
if len(settingAuth) != 2 || settingAuth[0] != "Bearer "+jwt {
t.Fatalf("setting authorization = %v, want the pasted web JWT", settingAuth)
}
if !strings.Contains(out.String(), "Actions enabled for 1/1 repositories") {
t.Fatalf("output = %q", out.String())
}
}
func TestSettingRunOrganizationUpdatesOnlyChangedRepositories(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, errOut := iostreams.TestTTY()
_, _ = in.WriteString(confirmationPhrase("enable", "acme") + "\n")
var putIDs []string
clientFactory := func() (*http.Client, error) {
return testutil.NewTestHTTPClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/orgs/acme/repos":
if r.URL.Query().Get("page") == "1" {
writeJSON(w, `[{"id":101,"name":"first","full_name":"acme/first"},{"id":102,"name":"second","full_name":"acme/second"}]`)
return
}
writeJSON(w, `[]`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/101/actions/setting":
writeJSON(w, `{"action_enabled":true,"block_all_new_pipelines":false}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/102/actions/setting":
writeJSON(w, `{"action_enabled":false,"block_all_new_pipelines":true}`)
case r.Method == http.MethodPut:
putIDs = append(putIDs, r.URL.Path)
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})), nil
}
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Org: "acme",
Action: "enable",
readPassword: hiddenJWTReader(t, jwt),
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
if len(putIDs) != 1 || putIDs[0] != "/api/v2/projects/102/actions/setting" {
t.Fatalf("PUT paths = %v, want only project 102", putIDs)
}
if !strings.Contains(out.String(), "Actions enabled for 1/2 repositories") {
t.Fatalf("output = %q", out.String())
}
preview := errOut.String()
if !strings.Contains(preview, "Actions will be enabled for 1 of 2 repositories:") || !strings.Contains(preview, "acme/first: skipped (Actions already enabled)") {
t.Fatalf("preview = %q, want changed count and skipped reason", preview)
}
if !strings.Contains(preview, "Fetching repositories in organization acme (page 1)...") ||
!strings.Contains(preview, "Reading current Actions settings for 2 repositories...") {
t.Fatalf("preview = %q, want batch progress messages", preview)
}
}
func TestSettingRunDoesNotWriteWhenConfirmationIsRejected(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, _, errOut := iostreams.TestTTY()
_, _ = in.WriteString("no\n")
putCalled := false
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
writeJSON(w, `{"action_enabled":false}`)
case r.Method == http.MethodPut:
putCalled = true
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
readPassword: hiddenJWTReader(t, jwt),
})
if err == nil || !strings.Contains(err.Error(), "confirmation did not match") {
t.Fatalf("settingRun() error = %v, want rejected confirmation", err)
}
if !strings.Contains(errOut.String(), "! Type \"enable Actions for owner/repo\" to confirm:") {
t.Fatalf("confirmation prompt = %q, want the family-style confirmation phrase", errOut.String())
}
if putCalled {
t.Fatal("PUT called after rejected confirmation")
}
}
func TestSettingRunSkipsWriteAndConfirmationWhenAlreadyDesired(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, _, out, errOut := iostreams.TestTTY()
putCalled := false
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
writeJSON(w, `{"action_enabled":true}`)
case r.Method == http.MethodPut:
putCalled = true
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
readPassword: hiddenJWTReader(t, jwt),
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
if putCalled {
t.Fatal("PUT called even though Actions was already enabled")
}
if !strings.Contains(errOut.String(), "owner/repo: skipped (Actions already enabled)") {
t.Fatalf("preview output = %q", errOut.String())
}
if strings.Contains(errOut.String(), "to confirm:") {
t.Fatalf("confirmation prompt = %q, want no confirmation when nothing changes", errOut.String())
}
if !strings.Contains(out.String(), "Actions enabled for 0/1 repositories") {
t.Fatalf("output = %q", out.String())
}
}
func TestSettingRunRequiresYesInNonInteractiveMode(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
ioStreams, in, _, _ := iostreams.Test()
_, _ = in.WriteString(futureWebJWT(t) + "\n")
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
writeJSON(w, `{"action_enabled":false}`)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
WithToken: true,
})
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("settingRun() error = %v, want --yes guidance", err)
}
}
func TestSettingRunFailsFastInNonInteractiveModeWithoutJWT(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
ioStreams, _, _, _ := iostreams.Test()
var requests []string
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v5/repos/owner/repo" {
writeJSON(w, `{"id":"project-1"}`)
return
}
http.NotFound(w, r)
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
})
if err == nil || !strings.Contains(err.Error(), "--with-token") {
t.Fatalf("settingRun() error = %v, want --with-token guidance", err)
}
if cmdutil.ExitCode(err) != cmdutil.ExitUsage {
t.Fatalf("ExitCode() = %d, want %d (ExitUsage)", cmdutil.ExitCode(err), cmdutil.ExitUsage)
}
for _, request := range requests {
if strings.Contains(request, "/api/v2/") {
t.Fatalf("setting API called without a JWT: %v", requests)
}
}
}
func TestSettingRunWithTokenReadsJWTFromStdin(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, _ := iostreams.Test()
_, _ = in.WriteString(jwt + "\n")
var putAuth string
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
writeJSON(w, `{"action_enabled":false}`)
case r.Method == http.MethodPut:
putAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
WithToken: true,
Yes: true,
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
if putAuth != "Bearer "+jwt {
t.Fatalf("PUT authorization = %q, want the piped web JWT", putAuth)
}
if !strings.Contains(out.String(), "Actions enabled for 1/1 repositories") {
t.Fatalf("output = %q", out.String())
}
}
func TestValidateWebJWT(t *testing.T) {
future := time.Now().Add(time.Hour).Unix()
past := time.Now().Add(-time.Hour).Unix()
valid := futureWebJWT(t)
if got, err := validateWebJWT(" " + valid + " \n"); err != nil || got != valid {
t.Fatalf("validateWebJWT() = %q, %v; want trimmed token without error", got, err)
}
if _, err := validateWebJWT(testWebJWT(t, future)); err != nil {
t.Fatalf("validateWebJWT(future exp) error = %v, want nil", err)
}
if _, err := validateWebJWT(""); err == nil || !strings.Contains(err.Error(), "no web session JWT provided") {
t.Fatalf("validateWebJWT(\"\") error = %v, want empty-input usage error", err)
}
if _, err := validateWebJWT("not-a-jwt"); err == nil || !strings.Contains(err.Error(), "not a valid GitCode web-session JWT") {
t.Fatalf("validateWebJWT(bad shape) error = %v, want shape error", err)
}
if _, err := validateWebJWT(testWebJWT(t, past)); err == nil || !strings.Contains(err.Error(), "expired") {
t.Fatalf("validateWebJWT(expired) error = %v, want expiry error", err)
}
brokenPayload := "header." + base64.RawURLEncoding.EncodeToString([]byte("not-json")) + ".sig"
if _, err := validateWebJWT(brokenPayload); err == nil || !strings.Contains(err.Error(), "failed to decode the web-session JWT payload") {
t.Fatalf("validateWebJWT(broken payload) error = %v, want decode error", err)
}
}
func TestNewCmdSettingParsesWithTokenFlag(t *testing.T) {
var opts *SettingOptions
cmd := NewCmdSetting(cmdutil.TestFactory(), func(received *SettingOptions) error {
opts = received
return nil
})
cmd.SetArgs([]string{"enable", "-R", "owner/repo", "--with-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if opts == nil || !opts.WithToken {
t.Fatalf("with-token flag = %#v, want opts.WithToken true", opts)
}
}
func TestNewCmdSettingAllowsDefaultRepository(t *testing.T) {
var opts *SettingOptions
cmd := NewCmdSetting(cmdutil.TestFactory(), func(received *SettingOptions) error {
opts = received
return nil
})
cmd.SetArgs([]string{"enable"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v, want no error when the repository is inferred", err)
}
if opts == nil || opts.Repository != "" || opts.Org != "" {
t.Fatalf("opts = %#v, want empty explicit target", opts)
}
}
func TestNewCmdSettingAcceptsOrganizationFlag(t *testing.T) {
var gotOrg string
cmd := NewCmdSetting(cmdutil.TestFactory(), func(opts *SettingOptions) error {
gotOrg = opts.Org
return nil
})
cmd.SetArgs([]string{"enable", "--org", "acme"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if gotOrg != "acme" {
t.Fatalf("organization target = %q, want acme", gotOrg)
}
}
func TestNewCmdSettingRejectsOrganizationInRepoFlag(t *testing.T) {
cmd := NewCmdSetting(cmdutil.TestFactory(), func(opts *SettingOptions) error {
return nil
})
cmd.SetArgs([]string{"enable", "-R", "acme"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "invalid repository format") {
t.Fatalf("Execute() error = %v, want -R to reject a bare organization name", err)
}
}
func TestNewCmdSettingRejectsRepoAndOrgTogether(t *testing.T) {
cmd := NewCmdSetting(cmdutil.TestFactory(), func(opts *SettingOptions) error {
return nil
})
cmd.SetArgs([]string{"enable", "-R", "owner/repo", "--org", "acme"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("Execute() error = %v, want mutual exclusion error", err)
}
}
func TestNewCmdSettingRejectsBlankOrganizationFlag(t *testing.T) {
cmd := NewCmdSetting(cmdutil.TestFactory(), func(opts *SettingOptions) error {
return nil
})
cmd.SetArgs([]string{"enable", "--org", " "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "blank value") {
t.Fatalf("Execute() error = %v, want a blank --org rejection", err)
}
}
func TestValidateTarget(t *testing.T) {
cases := []struct {
name string
repo string
org string
wantErr string
}{
{name: "repository", repo: "owner/repo"},
{name: "organization", org: "acme"},
{name: "missing target is inferred later", wantErr: ""},
{name: "both targets", repo: "owner/repo", org: "acme", wantErr: "mutually exclusive"},
{name: "organization with slash", org: "acme/team", wantErr: "without '/'"},
{name: "empty owner", repo: "/repo", wantErr: "both segments non-empty"},
{name: "empty name", repo: "owner/", wantErr: "both segments non-empty"},
{name: "only slash", repo: "/", wantErr: "both segments non-empty"},
{name: "bare name", repo: "acme", wantErr: "invalid repository format"},
{name: "blank repository flag", repo: " ", wantErr: "blank value"},
{name: "blank organization flag", org: "\t ", wantErr: "blank value"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
err := validateTarget(&SettingOptions{Repository: testCase.repo, Org: testCase.org})
if testCase.wantErr == "" {
if err != nil {
t.Fatalf("validateTarget() error = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), testCase.wantErr) {
t.Fatalf("validateTarget() error = %v, want error containing %q", err, testCase.wantErr)
}
})
}
}
func TestListAllOrganizationRepositoriesPaginates(t *testing.T) {
firstPage := make([]api.Repository, organizationRepoPageSize)
for i := range firstPage {
firstPage[i] = api.Repository{ID: i + 1, Name: "repo"}
}
secondPage := []api.Repository{{ID: 101, Name: "last"}}
var paths []string
client := api.NewClientFromHTTP(testutil.NewTestHTTPClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
switch r.URL.Query().Get("page") {
case "1":
writeJSONValue(w, firstPage)
case "2":
writeJSONValue(w, secondPage)
default:
writeJSONValue(w, []api.Repository{})
}
})))
repositories, err := listAllOrganizationRepositories(nil, client, "acme")
if err != nil {
t.Fatalf("listAllOrganizationRepositories() error = %v", err)
}
if len(repositories) != 101 {
t.Fatalf("repository count = %d, want 101", len(repositories))
}
if len(paths) != 3 || !strings.Contains(paths[0], "page=1") || !strings.Contains(paths[1], "page=2") || !strings.Contains(paths[2], "page=3") {
t.Fatalf("paths = %v, want pages 1 to 3", paths)
}
}
func TestListAllOrganizationRepositoriesContinuesPastShortPage(t *testing.T) {
var pages []string
client := api.NewClientFromHTTP(testutil.NewTestHTTPClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
pages = append(pages, page)
switch page {
case "1":
writeJSONValue(w, []api.Repository{{ID: 1}, {ID: 2}})
case "2":
writeJSONValue(w, []api.Repository{{ID: 3}})
default:
writeJSONValue(w, []api.Repository{})
}
})))
repositories, err := listAllOrganizationRepositories(nil, client, "acme")
if err != nil {
t.Fatalf("listAllOrganizationRepositories() error = %v", err)
}
if len(repositories) != 3 {
t.Fatalf("repository count = %d, want 3", len(repositories))
}
if len(pages) != 3 {
t.Fatalf("pages = %v, want the listing to continue past the short first page", pages)
}
}
func TestListAllOrganizationRepositoriesCapsPages(t *testing.T) {
client := api.NewClientFromHTTP(testutil.NewTestHTTPClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSONValue(w, []api.Repository{{ID: 1}})
})))
_, err := listAllOrganizationRepositories(nil, client, "acme")
if err == nil || !strings.Contains(err.Error(), "exceeded") {
t.Fatalf("listAllOrganizationRepositories() error = %v, want page cap error", err)
}
}
func TestSettingRunEmitsJSONSummaryToStdout(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, errOut := iostreams.TestTTY()
_, _ = in.WriteString(confirmationPhrase("enable", "owner/repo") + "\n")
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
writeJSON(w, `{"action_enabled":false,"block_all_new_pipelines":true}`)
case r.Method == http.MethodPut:
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "enable",
readPassword: hiddenJWTReader(t, jwt),
JSON: true,
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
var summary SettingSummary
if err := json.Unmarshal(out.Bytes(), &summary); err != nil {
t.Fatalf("stdout is not a single JSON document: %v\nstdout = %q", err, out.String())
}
if summary.Action != "enable" || !summary.ActionEnabled || summary.Organization != "" {
t.Fatalf("summary = %#v, want enable with no organization", summary)
}
if len(summary.Repositories) != 1 {
t.Fatalf("repositories = %#v, want exactly one entry", summary.Repositories)
}
got := summary.Repositories[0]
if got.Repository != "owner/repo" || got.ProjectID != "project-1" || got.Status != statusUpdated ||
got.PreviousActionEnabled || !got.ActionEnabled {
t.Fatalf("repository result = %#v, want an updated owner/repo entry", got)
}
if strings.Contains(out.String(), "Actions will be") || strings.Contains(out.String(), "Warning") {
t.Fatalf("stdout leaked prompt/preview text: %q", out.String())
}
if !strings.Contains(errOut.String(), "Actions will be enabled for 1 of 1 repositories:") ||
!strings.Contains(errOut.String(), "! Type \"enable Actions for owner/repo\" to confirm:") {
t.Fatalf("stderr = %q, want preview and confirmation prompt", errOut.String())
}
}
func TestSettingRunReportsPartialFailure(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, errOut := iostreams.TestTTY()
_, _ = in.WriteString(confirmationPhrase("enable", "acme") + "\n")
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/orgs/acme/repos":
if r.URL.Query().Get("page") == "1" {
writeJSON(w, `[{"id":101,"name":"first","full_name":"acme/first"},{"id":102,"name":"second","full_name":"acme/second"}]`)
return
}
writeJSON(w, `[]`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/101/actions/setting":
writeJSON(w, `{"action_enabled":false}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/102/actions/setting":
writeJSON(w, `{"action_enabled":false}`)
case r.Method == http.MethodPut && r.URL.Path == "/api/v2/projects/101/actions/setting":
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPut && r.URL.Path == "/api/v2/projects/102/actions/setting":
http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Org: "acme",
Action: "enable",
readPassword: hiddenJWTReader(t, jwt),
JSON: true,
})
if err == nil {
t.Fatal("settingRun() error = nil, want a partial failure error")
}
if cmdutil.ExitCode(err) != cmdutil.ExitError {
t.Fatalf("ExitCode() = %d, want %d (ExitError)", cmdutil.ExitCode(err), cmdutil.ExitError)
}
var summary SettingSummary
if jsonErr := json.Unmarshal(out.Bytes(), &summary); jsonErr != nil {
t.Fatalf("stdout is not a single JSON document: %v\nstdout = %q", jsonErr, out.String())
}
statuses := map[string]string{}
for _, repository := range summary.Repositories {
statuses[repository.Repository] = repository.Status
}
if statuses["acme/first"] != statusUpdated || statuses["acme/second"] != statusFailed {
t.Fatalf("statuses = %v, want acme/first updated and acme/second failed", statuses)
}
if !strings.Contains(errOut.String(), "acme/second") {
t.Fatalf("stderr = %q, want the failing repository detail", errOut.String())
}
}
func TestSettingRunDisablesActionsForOneRepository(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, in, out, errOut := iostreams.TestTTY()
_, _ = in.WriteString(confirmationPhrase("disable", "owner/repo") + "\n")
var putBody map[string]any
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/repos/owner/repo":
writeJSON(w, `{"id":"project-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
writeJSON(w, `{"action_enabled":true,"block_all_new_pipelines":true}`)
case r.Method == http.MethodPut && r.URL.Path == "/api/v2/projects/project-1/actions/setting":
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read PUT body: %v", err)
return
}
if err := json.Unmarshal(body, &putBody); err != nil {
t.Errorf("decode PUT body: %v", err)
}
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Repository: "owner/repo",
Action: "disable",
readPassword: hiddenJWTReader(t, jwt),
})
if err != nil {
t.Fatalf("settingRun() error = %v", err)
}
if putBody["action_enabled"] != false {
t.Fatalf("PUT body = %#v, want action_enabled false", putBody)
}
if putBody["block_all_new_pipelines"] != true {
t.Fatalf("PUT body = %#v, want the unrelated field preserved", putBody)
}
if !strings.Contains(errOut.String(), "! Type \"disable Actions for owner/repo\" to confirm:") {
t.Fatalf("confirmation prompt = %q, want the disable phrase", errOut.String())
}
if !strings.Contains(out.String(), "Actions disabled for 1/1 repositories") {
t.Fatalf("output = %q", out.String())
}
}
func TestSettingRunAbortsBeforeWritingWhenReadFails(t *testing.T) {
t.Setenv("GC_TOKEN", "test-token")
jwt := futureWebJWT(t)
ioStreams, _, _, _ := iostreams.TestTTY()
var puts []string
clientFactory := settingTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v5/orgs/acme/repos":
if r.URL.Query().Get("page") == "1" {
writeJSON(w, `[{"id":101,"name":"first","full_name":"acme/first"},{"id":102,"name":"second","full_name":"acme/second"}]`)
return
}
writeJSON(w, `[]`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/101/actions/setting":
writeJSON(w, `{"action_enabled":false}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/projects/102/actions/setting":
http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError)
case r.Method == http.MethodPut:
puts = append(puts, r.URL.Path)
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
err := settingRun(&SettingOptions{
IO: ioStreams,
HttpClient: clientFactory,
Org: "acme",
Action: "enable",
Yes: true,
readPassword: hiddenJWTReader(t, jwt),
})
if err == nil || !strings.Contains(err.Error(), "acme/second") {
t.Fatalf("settingRun() error = %v, want the unreadable repository named", err)
}
if len(puts) != 0 {
t.Fatalf("PUT paths = %v, want no write while a repository state is unknown", puts)
}
}
func confirmationPhrase(action, target string) string {
return fmt.Sprintf("%s Actions for %s", action, target)
}
func hiddenJWTReader(t *testing.T, jwt string) terminalPasswordReader {
t.Helper()
return func(fd int) ([]byte, error) {
return []byte(jwt), nil
}
}
func futureWebJWT(t *testing.T) string {
t.Helper()
return testWebJWT(t, time.Now().Add(time.Hour).Unix())
}
func testWebJWT(t *testing.T, exp int64) string {
t.Helper()
segment := func(value any) string {
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal JWT segment: %v", err)
}
return base64.RawURLEncoding.EncodeToString(encoded)
}
header := segment(map[string]string{"alg": "HS512", "typ": "JWT"})
payload := segment(map[string]any{"exp": exp, "sub": "tester"})
return header + "." + payload + ".signature"
}
func settingTestClient(handler http.HandlerFunc) func() (*http.Client, error) {
return func() (*http.Client, error) {
return testutil.NewTestHTTPClient(handler), nil
}
}
func writeJSON(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}
func writeJSONValue(w http.ResponseWriter, value any) {
body, _ := json.Marshal(value)
writeJSON(w, string(body))
}