Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions integration-tests/api_curl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package tests

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"slices"
"strings"
"sync"
"testing"

"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/upsun/cli/pkg/mockapi"
)
Expand Down Expand Up @@ -106,3 +109,163 @@ func TestApiCurlCommand(t *testing.T) {
assert.Empty(t, stdOut)
assert.Equal(t, 2, state.getFetches())
}

type recordedRequest struct {
Method string
Path string
Header http.Header
Body string
}

// requestRecorder records the requests a handler receives.
type requestRecorder struct {
mu sync.Mutex
reqs []recordedRequest
next http.Handler
}

func (r *requestRecorder) ServeHTTP(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(strings.NewReader(string(body)))
r.mu.Lock()
r.reqs = append(r.reqs, recordedRequest{
Method: req.Method, Path: req.URL.Path, Header: req.Header.Clone(), Body: string(body),
})
r.mu.Unlock()
r.next.ServeHTTP(w, req)
}

func (r *requestRecorder) requests() []recordedRequest {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.reqs)
}

// requestsTo returns the recorded requests to a path.
func (r *requestRecorder) requestsTo(path string) []recordedRequest {
return slices.DeleteFunc(r.requests(), func(req recordedRequest) bool { return req.Path != path })
}

// TestApiCurl_RejectsHost checks that a path with a host is refused without sending a request.
// The path is appended to the API URL, so the host check is a second safeguard against sending the token elsewhere.
func TestApiCurl_RejectsHost(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()
apiRecorder := &requestRecorder{next: mockapi.NewHandler(t)}
apiServer := httptest.NewServer(apiRecorder)
defer apiServer.Close()

f := newCommandFactory(t, apiServer.URL, authServer.URL)
for _, path := range []string{"https://example.com/steal", "//example.com/steal"} {
stdout, stderr, err := f.RunCombinedOutput("api:curl", path)
assertExitCode(t, 1, err)
assert.Empty(t, stdout)
assert.Contains(t, stderr, "Invalid path: "+path)
}
assert.Empty(t, apiRecorder.requests())
}

func TestApiCurl_Options(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()
apiHandler := mockapi.NewHandler(t)
apiHandler.HandleFunc("/curl-test", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("success"))
})
apiRecorder := &requestRecorder{next: apiHandler}
apiServer := httptest.NewServer(apiRecorder)
defer apiServer.Close()

f := newCommandFactory(t, apiServer.URL, authServer.URL)

assert.Equal(t, "success", f.Run("api:curl", "/curl-test", "-X", "PATCH",
"--json", `{"foo":"bar"}`, "-H", "X-Test: one", "-H", "X-Other: two"))
reqs := apiRecorder.requestsTo("/curl-test")
require.Len(t, reqs, 1)
assert.Equal(t, "PATCH", reqs[0].Method)
assert.JSONEq(t, `{"foo":"bar"}`, reqs[0].Body)
assert.Equal(t, "application/json", reqs[0].Header.Get("Content-Type"))
assert.Equal(t, "application/json", reqs[0].Header.Get("Accept"))
assert.Equal(t, "one", reqs[0].Header.Get("X-Test"))
assert.Equal(t, "two", reqs[0].Header.Get("X-Other"))
assert.Equal(t, "Bearer access-token-1", reqs[0].Header.Get("Authorization"))

_, stderr, err := f.RunCombinedOutput("api:curl", "/curl-test", "--json", "{invalid")
assert.Error(t, err)
assert.Contains(t, stderr, "The value of --json contains invalid JSON.")
assert.Len(t, apiRecorder.requestsTo("/curl-test"), 1)
}

// TestApiCurl_CensorsToken checks that the access token is replaced by "[token]" in verbose output.
func TestApiCurl_CensorsToken(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()
apiHandler := mockapi.NewHandler(t)
apiHandler.HandleFunc("/curl-test", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("success"))
})
apiServer := httptest.NewServer(apiHandler)
defer apiServer.Close()

f := newCommandFactory(t, apiServer.URL, authServer.URL)

for _, verbosity := range []string{"-v", "-vv"} {
stdout, stderr, err := f.RunCombinedOutput("api:curl", "/curl-test", verbosity)
require.NoError(t, err)
assert.Equal(t, "success", stdout)
assert.Contains(t, stderr, "Running command: curl -H 'Authorization: Bearer [token]'")
assert.NotContains(t, stderr, "access-token-1")
if verbosity == "-vv" {
// The curl --verbose output.
assert.Contains(t, stderr, "> Authorization: Bearer [token]")
}
}
}

// TestCurlCommands_BasePath checks the base path of each of the curl commands.
func TestCurlCommands_BasePath(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()
apiHandler := mockapi.NewHandler(t)
apiRecorder := &requestRecorder{next: apiHandler}
apiServer := httptest.NewServer(apiRecorder)
defer apiServer.Close()

projectID := mockapi.ProjectID()
envPath := "/projects/" + projectID + "/environments/main"
apiHandler.SetProjects([]*mockapi.Project{{
ID: projectID,
Links: mockapi.MakeHALLinks(
"self=/projects/"+projectID,
"environments=/projects/"+projectID+"/environments",
),
DefaultBranch: "main",
}})
apiHandler.SetEnvironments([]*mockapi.Environment{makeEnv(projectID, "main", "production", "active", nil)})
apiHandler.SetOrgs([]*mockapi.Org{makeOrg("org-id-1", "org-1", "Org 1", "my-user-id", "flexible")})

cases := []struct {
args []string
path string
}{
{[]string{"api:curl"}, "/curl-test"},
{[]string{"project:curl", "-p", projectID}, "/projects/" + projectID + "/curl-test"},
{[]string{"environment:curl", "-p", projectID, "-e", "main"}, envPath + "/curl-test"},
{[]string{"organization:curl", "-o", "org-1"}, "/organizations/org-id-1/curl-test"},
}
for _, c := range cases {
apiHandler.HandleFunc(c.path, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("success"))
})
}

f := newCommandFactory(t, apiServer.URL, authServer.URL)
for _, c := range cases {
assert.Equal(t, "success", f.Run(append(c.args, "/curl-test")...), c.args[0])
assert.Len(t, apiRecorder.requestsTo(c.path), 1, c.args[0])
}
}
15 changes: 15 additions & 0 deletions integration-tests/auth_api_token_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ func TestAuthAPITokenLogin_CommandAfterLogin(t *testing.T) {
assert.Contains(t, out, "acme")
}

// TestAuthAPITokenLogin_ConfiguredToken checks that the login is refused when an API token is set via config.
func TestAuthAPITokenLogin_ConfiguredToken(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()
apiServer := newMyUserAPI(t)
defer apiServer.Close()

f := newCommandFactory(t, apiServer.URL, authServer.URL)
_, stderr, err := f.RunInteractive(mockapi.ValidAPITokens[0]+"\n", "auth:api-token-login")
assertExitCode(t, 1, err)
assert.Contains(t, stderr, "An API token is already set via config")
assert.NotContains(t, stderr, "Please enter an API token")
}

func TestAuthAPITokenLogin_NonInteractive(t *testing.T) {
t.Parallel()
f := newCommandFactory(t, "", "")
Expand Down
56 changes: 56 additions & 0 deletions integration-tests/auth_logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tests

import (
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
Expand All @@ -26,6 +27,61 @@ func TestAuthLogout_Single(t *testing.T) {
_, stderr, err := f.RunCombinedOutput("auth:logout")
require.NoError(t, err)
assert.Contains(t, stderr, "logged out")
// The factory sets an API token via the environment, which cannot be removed.
assert.Contains(t, stderr, "Warning: an API token is set via config")
}

// TestAuthLogout_SingleRevokesSession checks that logging out of the current session revokes its tokens and deletes
// its files.
func TestAuthLogout_SingleRevokesSession(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()

f := newCommandFactory(t, "", authServer.URL)
f.extraEnv = append(f.extraEnv, EnvPrefix+"TOKEN=")
writeOAuthSession(t, f.home, "default", map[string]any{
"accessToken": "default-access", "refreshToken": "default-refresh", "tokenType": "bearer",
"expires": time.Now().Add(time.Hour).Unix(),
})
sessDir := filepath.Join(f.home, ".platform-test-cli", ".session")
certFile := filepath.Join(sessDir, "sess-cli-default", "ssh", "id_ed25519-cert.pub")
require.NoError(t, os.WriteFile(certFile, []byte("cert"), 0o600))

_, stderr, err := f.RunCombinedOutput("auth:logout")
require.NoError(t, err, "stderr: %s", stderr)
assert.Contains(t, stderr, "You are now logged out.")
assert.NotContains(t, stderr, "Warning: an API token is set via config")
assert.NotContains(t, stderr, "Other sessions exist")

assert.ElementsMatch(t, []string{"default-access", "default-refresh"}, authServer.RevokedTokens())
assert.NoFileExists(t, filepath.Join(sessDir, "sess-default", "sess-default.json"))
assert.NoFileExists(t, certFile)
assert.NoFileExists(t, filepath.Join(f.home, ".platform-test-cli", "auth", "default.json"))

_, _, err = f.RunCombinedOutput("auth:token", "--no-warn")
assertExitCode(t, 3, err)
}

// TestAuthLogout_OtherWithoutOtherSessions checks that --other keeps the current session.
func TestAuthLogout_OtherWithoutOtherSessions(t *testing.T) {
t.Parallel()
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()

f := newCommandFactory(t, "", authServer.URL)
f.extraEnv = append(f.extraEnv, EnvPrefix+"TOKEN=")
writeOAuthSession(t, f.home, "default", map[string]any{
"accessToken": "default-access", "refreshToken": "default-refresh", "tokenType": "bearer",
"expires": time.Now().Add(time.Hour).Unix(),
})

_, stderr, err := f.RunCombinedOutput("auth:logout", "--other")
require.NoError(t, err, "stderr: %s", stderr)
assert.Contains(t, stderr, "The current session ID is: default")
assert.Contains(t, stderr, "No other sessions exist.")
assert.Empty(t, authServer.RevokedTokens())
assert.Equal(t, "default-access", f.Run("auth:token", "--no-warn"))
}

// TestAuthLogout_OtherSessionsHint: when other sessions still exist after a single logout,
Expand Down
Loading
Loading