Skip to content
Open
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
55 changes: 44 additions & 11 deletions integration-tests/api_curl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,49 @@ import (
"net/http/httptest"
"os/exec"
"strings"
"sync"
"testing"

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

// tokenState holds mutable token state shared between the test and HTTP handlers.
type tokenState struct {
mu sync.Mutex
validToken string
tokenFetches int
}

func (s *tokenState) setToken(token string) {
s.mu.Lock()
defer s.mu.Unlock()
s.validToken = token
}

func (s *tokenState) getToken() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.validToken
}

// fetchToken increments the fetch count and returns the current valid token.
func (s *tokenState) fetchToken() string {
s.mu.Lock()
defer s.mu.Unlock()
s.tokenFetches++
return s.validToken
}

func (s *tokenState) getFetches() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.tokenFetches
}

func TestApiCurlCommand(t *testing.T) {
validToken := "valid-token"
state := &tokenState{validToken: "valid-token"}

mux := chi.NewMux()
if testing.Verbose() {
Expand All @@ -23,7 +57,7 @@ func TestApiCurlCommand(t *testing.T) {
mux.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/oauth2") {
if r.Header.Get("Authorization") != "Bearer "+validToken {
if r.Header.Get("Authorization") != "Bearer "+state.getToken() {
w.WriteHeader(http.StatusUnauthorized)
//nolint:lll
_ = json.NewEncoder(w).Encode(map[string]any{"error": "invalid_token", "error_description": "Invalid access token."})
Expand All @@ -33,11 +67,10 @@ func TestApiCurlCommand(t *testing.T) {
next.ServeHTTP(w, r)
})
})
var tokenFetches int
mux.Post("/oauth2/token", func(w http.ResponseWriter, _ *http.Request) {
tok := state.fetchToken()
w.WriteHeader(http.StatusOK)
tokenFetches++
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": validToken, "expires_in": 900, "token_type": "bearer"})
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": tok, "expires_in": 900, "token_type": "bearer"})
})
mux.Get("/users/me", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"id": "userID", "email": "me@example.com"})
Expand All @@ -52,24 +85,24 @@ func TestApiCurlCommand(t *testing.T) {

// Load the first token.
assert.Equal(t, "success", f.Run("api:curl", "/fake-api-path"))
assert.Equal(t, 1, tokenFetches)
assert.Equal(t, 1, state.getFetches())

// Revoke the access token and try the command again.
// The old token should be considered invalid, so the API call should return 401,
// and then the CLI should refresh the token and retry.
validToken = "new-valid-token"
state.setToken("new-valid-token")
assert.Equal(t, "success", f.Run("api:curl", "/fake-api-path"))
assert.Equal(t, 2, tokenFetches)
assert.Equal(t, 2, state.getFetches())

assert.Equal(t, "success", f.Run("api:curl", "/fake-api-path"))
assert.Equal(t, 2, tokenFetches)
assert.Equal(t, 2, state.getFetches())

// If --no-retry-401 and --fail are provided then the command should return exit code 22.
validToken = "another-new-valid-token"
state.setToken("another-new-valid-token")
stdOut, _, err := f.RunCombinedOutput("api:curl", "/fake-api-path", "--no-retry-401", "--fail")
exitErr := &exec.ExitError{}
assert.ErrorAs(t, err, &exitErr)
assert.Equal(t, 22, exitErr.ExitCode())
assert.Empty(t, stdOut)
assert.Equal(t, 2, tokenFetches)
assert.Equal(t, 2, state.getFetches())
}
144 changes: 116 additions & 28 deletions integration-tests/variable_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,66 +9,154 @@ import (
"github.com/upsun/cli/pkg/mockapi"
)

func TestVariableCreate(t *testing.T) {
// variableTestSetup holds common test infrastructure for variable tests.
type variableTestSetup struct {
authServer *httptest.Server
apiServer *httptest.Server
apiHandler *mockapi.Handler
projectID string
mainEnv *mockapi.Environment
factory *cmdFactory
}

// setupVariableTest creates the common test infrastructure for variable tests.
func setupVariableTest(t *testing.T) *variableTestSetup {
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()
t.Cleanup(authServer.Close)

apiHandler := mockapi.NewHandler(t)
apiServer := httptest.NewServer(apiHandler)
defer apiServer.Close()
t.Cleanup(apiServer.Close)

projectID := mockapi.ProjectID()

apiHandler.SetProjects([]*mockapi.Project{{
ID: projectID,
Links: mockapi.MakeHALLinks("self=/projects/"+projectID,
"environments=/projects/"+projectID+"/environments"),
Links: mockapi.MakeHALLinks(
"self=/projects/"+projectID,
"environments=/projects/"+projectID+"/environments",
),
DefaultBranch: "main",
}})
main := makeEnv(projectID, "main", "production", "active", nil)
main.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
main.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
envs := []*mockapi.Environment{main}
apiHandler.SetEnvironments(envs)

apiHandler.SetProjectVariables(projectID, []*mockapi.Variable{
mainEnv := makeEnv(projectID, "main", "production", "active", nil)
mainEnv.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
mainEnv.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}

return &variableTestSetup{
authServer: authServer,
apiServer: apiServer,
apiHandler: apiHandler,
projectID: projectID,
mainEnv: mainEnv,
factory: newCommandFactory(t, apiServer.URL, authServer.URL),
}
}

func TestVariableCreate(t *testing.T) {
s := setupVariableTest(t)
s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
s.apiHandler.SetProjectVariables(s.projectID, []*mockapi.Variable{
{
Name: "existing",
IsSensitive: true,
VisibleBuild: true,
},
})

f := newCommandFactory(t, apiServer.URL, authServer.URL)
f, p := s.factory, s.projectID

//nolint:lll
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", projectID, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value")
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value")
assert.NoError(t, err)
assert.Contains(t, stdErr, "Creating variable env:TEST on the environment main")

assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value"))
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value"))

//nolint:lll
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value")
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value")
assert.NoError(t, err)
assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+projectID)
assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+p)

//nolint:lll
assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "p"))
//nolint:lll
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "e"))
assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "p"))
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "e"))

_, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "existing", "-l", "p", "--value", "test")
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "existing", "-l", "p", "--value", "test")
assert.Error(t, err)
assert.Contains(t, stdErr, "The variable already exists")

//nolint:lll
_, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value2")
_, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value2")
assert.NoError(t, err)
assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "value"))
assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "value"))

assertTrimmed(t, "true", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime"))
_, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--visible-runtime", "false")
assertTrimmed(t, "true", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime"))
_, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--visible-runtime", "false")
assert.NoError(t, err)
assertTrimmed(t, "false", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime"))
assertTrimmed(t, "false", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime"))
}

func TestVariableCreateWithAppScope(t *testing.T) {
s := setupVariableTest(t)

// Set up deployment with app names for validation.
s.mainEnv.SetCurrentDeployment(&mockapi.Deployment{
WebApps: map[string]mockapi.App{
"app1": {Name: "app1", Type: "golang:1.23"},
"app2": {Name: "app2", Type: "php:8.3"},
},
Routes: make(map[string]any),
Links: mockapi.MakeHALLinks("self=/projects/" + s.projectID + "/environments/main/deployment/current"),
})
s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})

f, p := s.factory, s.projectID

// Test creating project-level variable with single app-scope.
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
"env:SCOPED", "--value", "val", "--app-scope", "app1")
assert.NoError(t, err)
assert.Contains(t, stdErr, "Creating variable env:SCOPED")

// Verify application_scope was set.
out := f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope")
assert.Contains(t, out, "app1")

// Test creating variable with multiple app scopes.
_, _, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
"env:MULTI", "--value", "val", "--app-scope", "app1", "--app-scope", "app2")
assert.NoError(t, err)

out = f.Run("var:get", "-p", p, "-l", "p", "env:MULTI", "-P", "application_scope")
assert.Contains(t, out, "app1")
assert.Contains(t, out, "app2")

// Test validation rejects invalid app names (when deployment exists).
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
"env:BAD", "--value", "val", "--app-scope", "nonexistent")
assert.Error(t, err)
assert.Contains(t, stdErr, "was not found")

// Test updating app-scope.
_, _, err = f.RunCombinedOutput("var:update", "-p", p, "-l", "p",
"env:SCOPED", "--app-scope", "app2")
assert.NoError(t, err)

out = f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope")
assert.Contains(t, out, "app2")
}

func TestVariableCreateWithAppScopeNoDeployment(t *testing.T) {
// Uses an environment without a deployment, so app-scope validation is skipped.
s := setupVariableTest(t)
s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})

f, p := s.factory, s.projectID

// Without a deployment, any app-scope value should be accepted.
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
"env:ANY_APP", "--value", "val", "--app-scope", "anyapp")
assert.NoError(t, err)
assert.Contains(t, stdErr, "Creating variable env:ANY_APP")

out := f.Run("var:get", "-p", p, "-l", "p", "env:ANY_APP", "-P", "application_scope")
assert.Contains(t, out, "anyapp")
}
10 changes: 4 additions & 6 deletions legacy/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
# Contributing

Development of the Platform.sh Legacy CLI happens in public in the
[GitHub repository](https://github.com/platformsh/legacy-cli).
Development of the Legacy CLI happens in public in the [GitHub repository](https://github.com/platformsh/legacy-cli).

Issues and pull requests submitted via GitHub are very welcome.

In the near future - to be confirmed - this may move to being a subtree split
of the new CLI repository at: https://github.com/platformsh/cli
The principal Upsun CLI repository is: https://github.com/platformsh/cli

## Developing locally

Expand All @@ -30,8 +28,8 @@ Run tests with:

## Developing in a docker container

If you don't have PHP installed locally or for other reasons want to do development on the
Platform.sh CLI inside a docker container, follow this procedure:
If you don't have PHP installed locally or for other reasons want to do development on the CLI inside a docker
container, follow this procedure:

Create a `.env` file based on the default one

Expand Down
14 changes: 1 addition & 13 deletions legacy/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
GO_TESTS_DIR=go-tests

.PHONY: composer-dev
composer-dev:
composer install --no-interaction
Expand All @@ -16,19 +14,9 @@ lint-phpstan: composer-dev
lint-php-cs-fixer: composer-dev
./vendor/bin/php-cs-fixer check --config .php-cs-fixer.dist.php --diff

.PHONY: lint-gofmt
lint-gofmt:
cd $(GO_TESTS_DIR) && go fmt ./...

.PHONY: lint-golangci
lint-golangci:
command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
cd $(GO_TESTS_DIR) && golangci-lint run

.PHONY: lint
lint: lint-gofmt lint-golangci lint-php-cs-fixer lint-phpstan
lint: lint-php-cs-fixer lint-phpstan

.PHONY: test
test:
./vendor/bin/phpunit --exclude-group slow
cd $(GO_TESTS_DIR) && go test -v -count=1 ./...
8 changes: 5 additions & 3 deletions legacy/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
The **Legacy** Platform.sh CLI is the legacy version of the command-line interface for [Platform.sh](https://platform.sh). For the **current Platform.sh CLI**, check [this repository](https://github.com/platformsh/cli).
The **Legacy** CLI is the legacy version of the command-line interface for [Upsun (formerly Platform.sh)](https://upsun.com).

For the **current Upsun CLI**, check [this repository](https://github.com/platformsh/cli).

## Install

Expand Down Expand Up @@ -53,7 +55,7 @@ scoop update platform

## Usage

You can run the Platform.sh CLI in your shell by typing `platform`.
You can run this CLI in your shell by typing `platform`.

platform

Expand Down Expand Up @@ -96,7 +98,7 @@ Other customization is available via environment variables, including:
* `PLATFORMSH_CLI_SSH_AUTO_LOAD_CERT`: set to 0 to disable automatic loading of an SSH certificate when running login or SSH commands
* `PLATFORMSH_CLI_REPORT_DEPRECATIONS`: set to 1 to enable PHP deprecation notices (suppressed by default). They will only be displayed in debug mode (`-vvv`).
* `CLICOLOR_FORCE`: set to 1 or 0 to force colorized output on or off, respectively
* `http_proxy` or `https_proxy`: specify a proxy for connecting to Platform.sh
* `http_proxy` or `https_proxy`: specify an HTTP proxy

## Known issues

Expand Down
4 changes: 2 additions & 2 deletions legacy/config-defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ detection:
- Unable to find stack
- Cannot build application of type
- Invalid deployment
- Could not perform a rolling deployment

# Pagination settings.
#
Expand Down Expand Up @@ -425,9 +426,8 @@ browser_login:
# css: ''
body: |
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkAQMAAABKLAcXAAAABlBMVEUAAADg4ODy8Xj7AAAAAXRSTlMAQObYZgAAAB5JREFUOMtj+I8EPozyRnlU4w1NMJhCcDT+hm2MAQAJBMb6YxK/8wAAAABJRU5ErkJggg=="
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJ1dWlkLTk2NDZkYjJkLTc3NjItNDc3Yy05MWMzLWE3OGZhNmY3ZTYzMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgNDAzLjM3IDI0OS4zIj48cGF0aCBkPSJtMTYzLjg4LDE0My40NmMtMi42LDAtNC44Mi0uNTctNi42Ny0xLjctMS44NS0xLjEzLTMuMjYtMi42NC00LjIyLTQuNTQtLjk2LTEuOS0xLjQ0LTQuMDMtMS40NC02LjM4di0yNC44OWgxMC44MnYyMi44YzAsMi4wMi41LDMuNTQsMS41MSw0LjU0czIuNDMsMS41MSw0LjI2LDEuNTFjMS42MywwLDMuMDgtLjM4LDQuMzMtMS4xNSwxLjI1LS43NywyLjI1LTEuODQsMi45OS0zLjIxLjc1LTEuMzcsMS4xMi0yLjk1LDEuMTItNC43MmwuOTQsOC44N2MtMS4yLDIuNjUtMi45Niw0Ljc5LTUuMjcsNi40Mi0yLjMxLDEuNjQtNS4xLDIuNDUtOC4zNywyLjQ1Wm0xMi45MS0uNzJ2LTguNjZoLS4yMnYtMjguMTNoMTAuODJ2MzYuNzloLTEwLjZaIi8+PHBhdGggZD0ibTIxNi42OSwxNDMuNDZjLTMuNTEsMC02LjMyLS44Mi04LjQ0LTIuNDUtMi4xMi0xLjYzLTMuMzctMy44Ny0zLjc1LTYuNzFsLjU4LS4wN3YyMy4zaC0xMC44MnYtNTEuNThoMTAuNnY4LjE1bC0uNjUtLjE0Yy41My0yLjY5LDEuOTctNC44Miw0LjMzLTYuMzgsMi4zNi0xLjU2LDUuMjctMi4zNCw4LjczLTIuMzRzNi4xOS43OCw4LjYyLDIuMzRjMi40MywxLjU2LDQuMywzLjc2LDUuNjMsNi42LDEuMzIsMi44NCwxLjk4LDYuMTgsMS45OCwxMC4wM3MtLjcsNy4yOS0yLjA5LDEwLjE3Yy0xLjQsMi44OS0zLjM0LDUuMTItNS44NCw2LjcxLTIuNSwxLjU5LTUuNDYsMi4zOC04Ljg3LDIuMzhabS0zLjAzLTguNjZjMi41NSwwLDQuNjItLjkxLDYuMi0yLjc0LDEuNTktMS44MywyLjM4LTQuNDIsMi4zOC03Ljc5cy0uODEtNS45NC0yLjQyLTcuNzJjLTEuNjEtMS43OC0zLjcyLTIuNjctNi4zMS0yLjY3cy00LjU2LjktNi4xNywyLjcxYy0xLjYxLDEuOC0yLjQyLDQuMzktMi40Miw3Ljc1cy44LDUuOTUsMi40Miw3Ljc1YzEuNjEsMS44LDMuNzIsMi43MSw2LjMxLDIuNzFaIi8+PHBhdGggZD0ibTI1NC40MiwxNDMuNDZjLTUuMzksMC05LjY3LTEuMDgtMTIuODQtMy4yNS0zLjE3LTIuMTctNC45MS01LjE1LTUuMTktOC45NWg5LjY3Yy4yNCwxLjYzLDEuMDcsMi44NywyLjQ5LDMuNzEsMS40Mi44NCwzLjM4LDEuMjYsNS44OCwxLjI2LDIuMjYsMCwzLjkxLS4zMyw0Ljk0LS45NywxLjAzLS42NSwxLjU1LTEuNTcsMS41NS0yLjc4LDAtLjkxLS4zLTEuNjItLjktMi4xMy0uNi0uNS0xLjctLjkyLTMuMjgtMS4yNmwtNS45Mi0xLjIzYy00LjM4LS45MS03LjYtMi4zLTkuNjctNC4xNS0yLjA3LTEuODUtMy4xLTQuMjQtMy4xLTcuMTgsMC0zLjU2LDEuMzctNi4zNCw0LjExLTguMzMsMi43NC0yLDYuNTYtMi45OSwxMS40Ny0yLjk5czguNzMuOTcsMTEuNjEsMi45MmMyLjg5LDEuOTUsNC40Nyw0LjY1LDQuNzYsOC4xMmgtOS42N2MtLjE5LTEuMjUtLjg3LTIuMi0yLjAyLTIuODVzLTIuNzktLjk3LTQuOTEtLjk3Yy0xLjkyLDAtMy4zNS4yOC00LjI5LjgzLS45NC41NS0xLjQxLDEuMzMtMS40MSwyLjM0LDAsLjg3LjM4LDEuNTUsMS4xNSwyLjA2Ljc3LjUsMi4wNC45NSwzLjgyLDEuMzNsNi42NCwxLjM3YzMuNy43Nyw2LjUsMi4yLDguNCw0LjI5LDEuOSwyLjA5LDIuODUsNC41NiwyLjg1LDcuMzksMCwzLjYxLTEuNDEsNi40MS00LjIyLDguNC0yLjgxLDItNi43OSwyLjk5LTExLjk0LDIuOTlaIi8+PHBhdGggZD0ibTI4Ni45NiwxNDMuNDZjLTIuNiwwLTQuODItLjU3LTYuNjctMS43LTEuODUtMS4xMy0zLjI2LTIuNjQtNC4yMi00LjU0LS45Ni0xLjktMS40NC00LjAzLTEuNDQtNi4zOHYtMjQuODloMTAuODJ2MjIuOGMwLDIuMDIuNSwzLjU0LDEuNTEsNC41NCwxLjAxLDEuMDEsMi40MywxLjUxLDQuMjYsMS41MSwxLjYzLDAsMy4wOC0uMzgsNC4zMy0xLjE1LDEuMjUtLjc3LDIuMjUtMS44NCwyLjk5LTMuMjEuNzQtMS4zNywxLjEyLTIuOTUsMS4xMi00LjcybC45NCw4Ljg3Yy0xLjIsMi42NS0yLjk2LDQuNzktNS4yNyw2LjQyLTIuMzEsMS42NC01LjEsMi40NS04LjM3LDIuNDVabTEyLjkxLS43MnYtOC42NmgtLjIydi0yOC4xM2gxMC44MnYzNi43OWgtMTAuNloiLz48cGF0aCBkPSJtMzE3LjMzLDE0Mi43M3YtMzYuNzloMTAuNnY4LjY2aC4yMnYyOC4xM2gtMTAuODJabTI1LjYxLDB2LTIyLjhjMC0yLjAyLS41Mi0zLjU0LTEuNTUtNC41NC0xLjAzLTEuMDEtMi41NC0xLjUxLTQuNTEtMS41MS0xLjY4LDAtMy4xOS4zOS00LjUxLDEuMTUtMS4zMi43Ny0yLjM2LDEuODMtMy4xLDMuMTctLjc1LDEuMzUtMS4xMiwyLjkzLTEuMTIsNC43NmwtLjk0LTguODdjMS4yLTIuNjksMi45Ny00Ljg0LDUuMy02LjQ2LDIuMzMtMS42MSw1LjIxLTIuNDIsOC42Mi0yLjQyLDQuMDksMCw3LjIxLDEuMTQsOS4zOCwzLjQzLDIuMTYsMi4yOSwzLjI1LDUuMzUsMy4yNSw5LjJ2MjQuODloLTEwLjgyWiIvPjxnIGlkPSJ1dWlkLTg2Yjg5MTUxLWU2NWMtNGRhZi1iNWM2LTI2ZmEwYzhlMzk0ZCI+PHBhdGggZD0ibTg5Ljc5LDEwMy4wNGMxMS45NiwwLDIxLjYzLDkuNjksMjEuNjMsMjEuNjNoMjEuNjNjMC0yMy44OS0xOS4zNi00My4yNy00My4yNy00My4yN3MtNDMuMjcsMTkuMzYtNDMuMjcsNDMuMjdoMjEuNjNjLjA1LTExLjk2LDkuNzQtMjEuNjMsMjEuNjctMjEuNjNaIi8+PHBhdGggZD0ibTk3LjEyLDE0NWM4LjM0LTMuMDEsMTQuMjktMTAuOTgsMTQuMjktMjAuMzRoLTQzLjI1YzAsOS4zNiw1Ljk2LDE3LjM1LDE0LjI5LDIwLjM0di4zN2gtMzAuNjRjNy4zMywxMy40MywyMS42LDIyLjU0LDM4LDIyLjU0czMwLjY0LTkuMTEsMzgtMjIuNTRoLTMwLjY5di0uMzdaIi8+PC9nPjwvc3ZnPg=="
alt=""
width="100"
height="100"
class="icon">

Expand Down
Loading
Loading