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
7 changes: 0 additions & 7 deletions client/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ type MeshApiKeyStatus struct {
type MeshApiKeyClient interface {
Create(ctx context.Context, apiKey *MeshApiKey) (*MeshApiKey, error)
Read(ctx context.Context, uuid string) (*MeshApiKey, error)
// ReadSelf returns the key the request authenticates with. Unlike Read, it needs no APIKEY_LIST
// and also finds a hidden key, such as one bootstrapped from the meshStack configuration.
ReadSelf(ctx context.Context) (*MeshApiKey, error)
Update(ctx context.Context, uuid string, apiKey *MeshApiKey) (*MeshApiKey, error)
Delete(ctx context.Context, uuid string) error
}
Expand All @@ -63,10 +60,6 @@ func (c meshApiKeyClient) Update(ctx context.Context, uuid string, apiKey *MeshA
return c.meshObject.Put(ctx, uuid, apiKey)
}

func (c meshApiKeyClient) ReadSelf(ctx context.Context) (*MeshApiKey, error) {
return c.meshObject.Get(ctx, "self")
}

func (c meshApiKeyClient) Delete(ctx context.Context, uuid string) error {
return c.meshObject.Delete(ctx, uuid)
}
33 changes: 0 additions & 33 deletions client/api_key_permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,39 +43,6 @@ func (p ApiKeyPermissions) WorkspaceCodes() []string {
return codes
}

// Only keeps the codes of p that are among codes, in the order of p, and puts the codes p does
// not know into a last group named Other.
func (p ApiKeyPermissions) Only(codes []ApiPermission) ApiKeyPermissions {
remaining := slices.Clone(codes)
var only ApiKeyPermissions
for _, group := range p {
kept := ApiKeyPermissionGroup{Name: group.Name}
for _, suffixGroup := range group.Actions {
var held []ApiPermission
for _, code := range suffixGroup {
if index := slices.Index(remaining, code); index >= 0 {
remaining = slices.Delete(remaining, index, index+1)
held = append(held, code)
}
}
if len(held) > 0 {
kept.Actions = append(kept.Actions, held)
}
}
if len(kept.Actions) > 0 {
only = append(only, kept)
}
}
if len(remaining) > 0 {
other := ApiKeyPermissionGroup{Name: "Other"}
for _, code := range remaining {
other.Actions = append(other.Actions, []ApiPermission{code})
}
only = append(only, other)
}
return only
}

func (p ApiKeyPermissions) MarkdownString() string {
var lines []string
for _, group := range p {
Expand Down
13 changes: 0 additions & 13 deletions client/api_key_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,6 @@ func TestPermissions(t *testing.T) {
}
})

t.Run("Only keeps the held codes in their groups, and the unknown ones in Other", func(t *testing.T) {
held := Permissions.Only([]ApiPermission{
"WORKSPACE_LIST", "NOT_A_KNOWN_CODE", "TENANT_DELETE", "ADM_TENANT_IMPORT", "MANAGED_TENANT_IMPORT", "ADM_TENANT_DELETE",
})

assert.Equal(t, ApiKeyPermissions{
{Name: "Tenants", Actions: [][]ApiPermission{{"TENANT_DELETE", "ADM_TENANT_DELETE"}, {"MANAGED_TENANT_IMPORT", "ADM_TENANT_IMPORT"}}},
{Name: "Workspaces", Actions: [][]ApiPermission{{"WORKSPACE_LIST"}}},
{Name: "Other", Actions: [][]ApiPermission{{"NOT_A_KNOWN_CODE"}}},
}, held)
assert.Empty(t, Permissions.Only(nil))
})

t.Run("MarkdownString merges the codes into patterns that stand for no other code", func(t *testing.T) {
permissions := ApiKeyPermissions{
{Name: "Tenants", Actions: [][]ApiPermission{
Expand Down
35 changes: 22 additions & 13 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -95,12 +94,12 @@ Replace the same command line with 'api-docs' in place of 'api' to describe the
return writeErr
}
// The body is on stdout already, so the error says only what it adds.
if httpErr.IsClientError() && !httpErr.IsUnauthorized() && !httpErr.IsForbidden() && !r.objectNotFound(httpErr) {
return fmt.Errorf("meshStack answered HTTP %d. Run %s to see what the API takes",
httpErr.StatusCode, docsCommand(cmd, args))
if r.docsHelpWith(httpErr) {
return withHint(fmt.Sprintf("%s. Run %s to see what the API takes",
httpErr.Summary(), docsCommand(cmd, args)), httpErr)
}
// What wraps the HTTP error, such as the auth scope of a 403, stays.
return errors.New(strings.Replace(err.Error(), httpErr.Error(), fmt.Sprintf("meshStack answered HTTP %d", httpErr.StatusCode), 1))
return withHint(strings.Replace(err.Error(), httpErr.Error(), httpErr.Summary(), 1), httpErr)
}
if err != nil {
return err
Expand All @@ -114,16 +113,26 @@ Replace the same command line with 'api-docs' in place of 'api' to describe the
return cmd
}

// objectNotFound tells a 404 for a path that exists, whose object is missing, from one for a path that
// does not: only the latter is something the API docs help with.
func (r *request) objectNotFound(httpErr client.HttpError) bool {
if !httpErr.IsNotFound() || !r.documented {
// docsHelpWith is false for a 404 of a path meshStack serves nothing at, and of an object missing at a
// documented path: the API docs say nothing about either.
func (r *request) docsHelpWith(httpErr client.HttpError) bool {
switch {
case !httpErr.IsClientError(), httpErr.IsUnauthorized(), httpErr.IsForbidden():
return false
case httpErr.IsNotFound():
return !r.documented && httpErr.ErrorCode() != noHandlerFound
default:
return true
}
var answer struct {
ErrorCode string `json:"errorCode"`
}

// withHint adds the hint on a line of its own. Stdout carries the raw body, which the error does not
// repeat.
func withHint(message string, httpErr client.HttpError) error {
if hint := httpErr.ClientVersionHint(); hint != "" {
message += "\n" + hint
}
return json.Unmarshal(httpErr.ResponseBody, &answer) != nil || answer.ErrorCode != noHandlerFound
return errors.New(message)
}

// noHandlerFound is the errorCode of meshStack's answer to a path it serves nothing at.
Expand All @@ -149,7 +158,7 @@ func (r *request) negotiate(ctx context.Context, spec openapi.Spec, selector ope
// The request goes out as given where the docs know no better, because they can lag behind the
// meshStack it is sent to.
selected, err := spec.Select(selector)
r.documented = err == nil
r.documented = err == nil && len(selected.Operations) > 0
if err != nil {
if !openapi.ParseMediaType(r.header.Get("Accept")).ApiVersion.IsZero() {
slog.WarnContext(ctx, fmt.Sprintf("Sending the request as given: %s", err))
Expand Down
28 changes: 25 additions & 3 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
blockV2Preview = "application/vnd.meshcloud.api.meshbuildingblock.v2-preview.hal+json"
)

func TestApi(t *testing.T) {

Check failure on line 31 in cmd/api/api_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=meshcloud_meshstack-cli&issues=AaGhYDflP9WCyXNNU4TC&open=AaGhYDflP9WCyXNNU4TC&pullRequest=65
meshStack := newFakeMeshStack(t)
testlogin.LoggedInTo(t, meshStack.URL)
apiDocs, err := os.ReadFile(apiDocsFile)
Expand Down Expand Up @@ -128,9 +128,9 @@

t.Run("writes the body of a failed answer, and fails with a hint to api-docs on a client error", func(t *testing.T) {
for status, wantError := range map[int]string{
gohttp.StatusConflict: "meshStack answered HTTP 409. Run meshstack api-docs /api/x -H 'Accept: application/json' -X DELETE to see what the API takes",
gohttp.StatusBadGateway: "meshStack answered HTTP 502",
gohttp.StatusForbidden: "auth scope profile default: meshStack answered HTTP 403",
gohttp.StatusConflict: "meshStack answered HTTP 409: still in use. Run meshstack api-docs /api/x -H 'Accept: application/json' -X DELETE to see what the API takes",
gohttp.StatusBadGateway: "meshStack answered HTTP 502: still in use",
gohttp.StatusForbidden: "auth scope profile default: meshStack answered HTTP 403: still in use",
} {
meshStack.answer(status, `{"message":"still in use"}`)

Expand All @@ -141,6 +141,28 @@
}
})

t.Run("points to api-docs on a 404 only for a path the API docs do not list", func(t *testing.T) {
for path, wantError := range map[string]string{
"/api/x": "meshStack answered HTTP 404: gone (errorCode NotFound). Run meshstack api-docs /api/x -X GET -H 'Accept: application/json' to see what the API takes",
"/api/meshobjects/meshtenants/0c9ef6d1-5c5d-4b0c-9a4b-1e0e3c1f2a3b": "meshStack answered HTTP 404: gone (errorCode NotFound)",
} {
meshStack.answer(gohttp.StatusNotFound, `{"errorCode":"NotFound","message":"gone"}`)

_, err := meshStack.run(t, api.New(), "", path, "-H", "Accept: application/json")

require.EqualError(t, err, wantError)
}
})

t.Run("adds the clientVersionHint of a failed answer to the error", func(t *testing.T) {
meshStack.answer(gohttp.StatusNotFound, `{"errorCode":"NoHandlerFound","message":"no handler","errorId":"abc","clientVersionHint":"Upgrade the meshStack CLI to v0.9.0."}`)

stdout, err := meshStack.run(t, api.New(), "", "/api/x", "-H", "Accept: application/json")

require.EqualError(t, err, "meshStack answered HTTP 404: no handler (errorCode NoHandlerFound, errorId abc)\nUpgrade the meshStack CLI to v0.9.0.")
assert.Contains(t, stdout, "clientVersionHint")
})

t.Run("asks for the media type the API docs list for a path of no version", func(t *testing.T) {
meshStack.answer(gohttp.StatusOK, "plan")

Expand Down
2 changes: 1 addition & 1 deletion cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func newStatus() *cobra.Command {
where it comes from, who it is for, and until when it lasts. It never shows a secret.

A browser login is not renewed for this, so its token may show as expired, and it is renewed on
next use. An API key reads its own details from meshStack.`,
next use.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions())
Expand Down
4 changes: 2 additions & 2 deletions cmd/buildingblock/tfstate/tfstate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func TestTfstate(t *testing.T) {

require.NoError(t, ran.err)
assert.Contains(t, ran.log, "level=WARN")
assert.Contains(t, ran.log, "http error 500")
assert.Contains(t, ran.log, "meshStack answered HTTP 500")
})

t.Run("a refusal of meshStack says which rights the state takes", func(t *testing.T) {
Expand All @@ -265,7 +265,7 @@ func TestTfstate(t *testing.T) {
err := execChild(t, "GET").err

require.Error(t, err)
assert.Contains(t, err.Error(), "http error 403")
assert.Contains(t, err.Error(), "meshStack answered HTTP 403")
assert.Contains(t, err.Error(), "MANAGED_TFSTATE_LIST")
assert.Contains(t, err.Error(), "meshstack login --apikey")
})
Expand Down
12 changes: 0 additions & 12 deletions cmd/internal/markdown/credential.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@
| Credential | {{.Kind.Label}}
{{- with .ApiKey}}{{if eq $.Kind "manual"}} of API key{{end}} {{.ClientId}}{{else}}{{if and $token $token.ClientId}} of API key {{cell $token.ClientId}}{{end}}{{end -}}
, from {{range $i, $source := .Sources}}{{if $i}}, {{end}}{{cell $source}}{{end}} |
{{- with .ApiKey}}{{with .Details}}
| | {{cell .DisplayName}}, owned by workspace {{cell .OwnedByWorkspace}},
{{- if not .ExpiresAt.IsZero}} expires {{at .ExpiresAt}}
{{- else if .ExpiresOn}} expires on {{cell .ExpiresOn}}
{{- else}} never expires{{end}} |
{{- end}}{{end}}
{{- if and $token $token.User (not .ApiKey)}}
| | User {{cell $token.User}}{{with $token.Email}}{{if ne . $token.User}} ({{cell .}}){{end}}{{end}}{{if .OidcLogin}} with {{template "access" .}}{{end}} |
{{- else if .OidcLogin}}
Expand All @@ -29,12 +23,6 @@
| | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `meshstack login -p {{$.Profile}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}}
{{- else}}Token expires {{at .ExpiresAt}}{{end}}{{with .Workspace}}, for workspace {{cell .}}{{end}}
{{- else}}No token cached yet{{if ne .Kind "manual"}}, minted on next use{{end}}{{end}} |
{{- with .ApiKey}}{{with .Details}}
{{- range .PermissionGroups}}
| | • {{cell .Name}}: {{range $i, $pattern := .Patterns}}{{if $i}}, {{end}}`{{cell $pattern}}`{{end}} |
{{- else}}
| | No permissions |
{{- end}}{{end}}{{end}}
{{- range $i, $unused := .Unused}}
| {{if not $i}}Unused credentials{{end}} | • {{.Kind.Label}}
{{- with .ClientId}} {{cell .}}{{end}}
Expand Down
40 changes: 2 additions & 38 deletions cmd/internal/markdown/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/meshcloud/meshstack-cli/client"
"github.com/meshcloud/meshstack-cli/client/types/xurl"
"github.com/meshcloud/meshstack-cli/internal/auth"
"github.com/meshcloud/meshstack-cli/internal/auth/credential"
Expand Down Expand Up @@ -47,16 +46,13 @@ func TestABrowserLoginShowsItsUserAccessAndSessionEnd(t *testing.T) {
})
}

func TestAnApiKeyShowsItsPermissionsBelowTheCredential(t *testing.T) {
func TestAnApiKeyShowsItsClientIdAndNoCachedTokenYet(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
clientId := uuid.MustParse("11111111-45bf-42ba-a965-2097b9d0d181")
status := auth.CredentialStatus{
Profile: "ci", Endpoint: xurl.MustParsef("https://meshstack.example.com"),
Kind: credential.ApiKeyName, Sources: []string{"env MESHSTACK_API_KEY", "env MESHSTACK_API_SECRET"},
ApiKey: &auth.ApiKeyStatus{ClientId: clientId, Details: &auth.ApiKeyDetails{
DisplayName: "CI | nightly", OwnedByWorkspace: "ops",
PermissionGroups: client.ApiKeyPermissions{{Name: "Tenants", Actions: [][]client.ApiPermission{{"TENANT_LIST", "ADM_TENANT_LIST"}, {"TENANT_SAVE", "ADM_TENANT_SAVE"}}}},
}},
ApiKey: &auth.ApiKeyStatus{ClientId: clientId},
Unused: []auth.UnusedCredential{
{Kind: credential.ApiKeyName, ClientId: "22222222-45bf-42ba-a965-2097b9d0d181"},
{Kind: credential.OidcLoginName, Selected: true, Token: &auth.TokenStatus{User: "jane", Email: "jane@example.com"}},
Expand All @@ -72,9 +68,7 @@ func TestAnApiKeyShowsItsPermissionsBelowTheCredential(t *testing.T) {
"| Endpoint | https://meshstack.example.com |\n"+
"| Workspace | none |\n"+
"| Credential | API key 11111111-45bf-42ba-a965-2097b9d0d181, from env MESHSTACK_API_KEY, env MESHSTACK_API_SECRET |\n"+
`| | CI \| nightly, owned by workspace ops, never expires |`+"\n"+
"| | No token cached yet, minted on next use |\n"+
"| | • Tenants: `[ADM_]TENANT_(LIST\\|SAVE)` |\n"+
"| Unused credentials | • API key 22222222-45bf-42ba-a965-2097b9d0d181 |\n"+
"| | • Browser login jane (jane@example.com), selected by the profile |\n"+
"| | • API token, expired 1999-12-31 23:00 (1h ago) |", shown)
Expand All @@ -96,36 +90,6 @@ func TestAnExpiredApiTokenSaysItCannotBeRenewed(t *testing.T) {
assert.NotContains(t, shown, "| Workspace |", "the token carries its workspace")
}

func TestAnApiKeyShowsWhenItExpires(t *testing.T) {
for _, tt := range []struct {
name string
expiresIn time.Duration
expiresOn string
want string
}{
{name: "a key with an expiry time", expiresIn: 30 * 24 * time.Hour, want: "expires 2000-01-31 00:00 (in 30d)"},
{name: "a meshStack that sends only the expiry date", expiresOn: "2000-01-31", want: "expires on 2000-01-31"},
} {
t.Run(tt.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
details := &auth.ApiKeyDetails{DisplayName: "CI", OwnedByWorkspace: "ops", ExpiresOn: tt.expiresOn}
if tt.expiresIn != 0 {
details.ExpiresAt = time.Now().UTC().Add(tt.expiresIn)
}
status := auth.CredentialStatus{
Profile: "ci", Endpoint: xurl.MustParsef("https://meshstack.example.com"), Kind: credential.ApiKeyName,
ApiKey: &auth.ApiKeyStatus{Details: details},
}

shown, err := Execute(credentialOnly, status)

require.NoError(t, err)
assert.Contains(t, shown, "\n| | CI, owned by workspace ops, "+tt.want+" |\n")
})
})
}
}

func TestATimeIsShownWithHowFarAwayItIs(t *testing.T) {
for d, want := range map[time.Duration]string{
30 * time.Second: "now",
Expand Down
10 changes: 1 addition & 9 deletions cmd/internal/testacc/api_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ var getExceptions = map[openapi.PathTemplate]getException{
"/api/meshobjects/meshbuildingblockruns": {
query: map[string]objectField{"buildingBlockUuid": {"meshBuildingBlock", "uuid"}},
},
"/api/meshobjects/meshapikeys/self": {
apiKeyOnly: "it answers 404 to a request that authenticates with no meshApiKey",
},
"/api/meshobjects/meshbuildingblockruns/{buildingBlockRun}/plan-artifact": {
skip: "the API docs say it is in development, and answers 404 in normal operation",
},
Expand All @@ -48,16 +45,14 @@ type getException struct {
query map[string]objectField
// skip sends no GET, for the reason it gives.
skip string
// apiKeyOnly sends the GET only for the API key login, for the reason it gives.
apiKeyOnly string
}

// objectField is a field of the metadata of the first object of a kind.
type objectField struct {
kind, field string
}

func everyGetOperationAnswers(c *cli, apiKey bool) func(*testing.T) {
func everyGetOperationAnswers(c *cli) func(*testing.T) {
return func(t *testing.T) {
spec := devApiDocs(t)
objects := firstObjects{spec: spec, meshStack: c.client(t)}
Expand All @@ -71,9 +66,6 @@ func everyGetOperationAnswers(c *cli, apiKey bool) func(*testing.T) {
if exception.skip != "" {
t.Skip(exception.skip)
}
if exception.apiKeyOnly != "" && !apiKey {
t.Skip(exception.apiKeyOnly)
}
r, skip, err := objects.fill(t.Context(), operation, exception)
require.NoError(t, err)
if skip != "" {
Expand Down
5 changes: 3 additions & 2 deletions cmd/internal/testacc/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import (
func apiAsksForJson(c *cli) func(*testing.T) {
return func(t *testing.T) {
output, err := c.run("", "api", "/api/meshobjects/meshworkspaces", "-H", "Accept: application/json")
require.EqualErrorf(t, err, "meshStack answered HTTP 406. Run meshstack api-docs /api/meshobjects/meshworkspaces "+
"-X GET -H 'Accept: application/json' to see what the API takes", "a meshObject endpoint refuses application/json, output:\n%s", output)
require.ErrorContainsf(t, err, "meshStack answered HTTP 406: value in 'Accept' header not supported. (errorCode HttpMediaTypeNotAcceptable, errorId ",
"a meshObject endpoint refuses application/json, output:\n%s", output)
require.ErrorContains(t, err, "). Run meshstack api-docs /api/meshobjects/meshworkspaces -X GET -H 'Accept: application/json' to see what the API takes")

// A body of no metadata, which meshStack refuses before it creates anything.
output, err = c.run(`{}`, "api", "-X", "POST", "/api/meshobjects/meshworkspaces", "--request-json", "-")
Expand Down
6 changes: 2 additions & 4 deletions cmd/internal/testacc/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ func TestAccBrowserLogin(t *testing.T) {
completeKeycloakLogin(t, run.awaitStartURL(t), "read", admin.Username, admin.Password)
require.NoErrorf(t, run.wait(), "the browser login did not finish:\n%s", run.output.String())
})
t.Run("every GET operation answers the organization admin", everyGetOperationAnswers(c, false))
t.Run("every GET operation answers the organization admin", everyGetOperationAnswers(c))
t.Run("--apitoken logs in with the token auth token prints", apiTokenLogsIn(c))
}

Expand Down Expand Up @@ -238,7 +238,7 @@ func TestAccApiKeyLogin(t *testing.T) {
c.setEnv(envWorkspace, workspace)
}
t.Run("every list command answers", everyListCommandAnswers(c))
t.Run("every GET operation answers the API key", everyGetOperationAnswers(c, true))
t.Run("every GET operation answers the API key", everyGetOperationAnswers(c))
t.Run("a meshObject endpoint refuses JSON, and api sends --request-json in its media type", apiAsksForJson(c))
t.Run("api picks the profile for the self link of a list answer by its URL alone, with no endpoint in the environment", apiFollowsASelfLink(c))
t.Run("a wait ends once the run has ended", waitEndsTheRun(c))
Expand Down Expand Up @@ -283,8 +283,6 @@ func (c *cli) authToken(t *testing.T) string {
return token
}

// requireAuthStatus does not check whether this meshStack lets an API key read itself through
// /self yet, so it accepts the warnings about the key's details.
func requireAuthStatus(t *testing.T, c *cli, wantCredential string) {
t.Helper()
status, err := c.run("", "auth", "status")
Expand Down
Loading
Loading