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
37 changes: 37 additions & 0 deletions cmd/kosli/apiKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ type apiKeyMetadata struct {
LastUsedAt float64 `json:"last_used_at"`
}

// apiKeyMetadataTimestamps formats the created/expires/last-used timestamps
// of a key's metadata for table output.
func apiKeyMetadataTimestamps(key apiKeyMetadata) (createdAt, expiresAt, lastUsedAt string, err error) {
if createdAt, err = formattedTimestamp(key.CreatedAt, false); err != nil {
return
}
if expiresAt, err = optionalTimestamp(key.ExpiresAt); err != nil {
return
}
lastUsedAt, err = optionalTimestamp(key.LastUsedAt)
return
}

// parseExpiresAt converts a user-supplied --expires-at value into a Unix
// (epoch-second) timestamp. It accepts a bare epoch integer, or one of the
// date/time layouts below (interpreted as UTC). An empty string returns 0.
Expand Down Expand Up @@ -93,6 +106,30 @@ func printApiKeysAsTable(raw string, out io.Writer, page int) error {
return nil
}

// printApiKeyMetadataAsTable renders a single api key's metadata (the get
// response) as a table. The secret key value is never part of this response.
func printApiKeyMetadataAsTable(raw string, out io.Writer, page int) error {
var key apiKeyMetadata
if err := json.Unmarshal([]byte(raw), &key); err != nil {
return err
}

createdAt, expiresAt, lastUsedAt, err := apiKeyMetadataTimestamps(key)
if err != nil {
return err
}

rows := []string{
fmt.Sprintf("ID:\t%s", key.Id),
fmt.Sprintf("Description:\t%s", key.Description),
fmt.Sprintf("Created At:\t%s", createdAt),
fmt.Sprintf("Expires At:\t%s", expiresAt),
fmt.Sprintf("Last Used:\t%s", lastUsedAt),
}
tabFormattedPrint(out, []string{}, rows)
return nil
}
Comment thread
mbevc1 marked this conversation as resolved.

// optionalTimestamp formats an epoch timestamp, returning "N/A" when it is
// unset (nil, or a zero value meaning "never"/"not set").
func optionalTimestamp(epoch interface{}) (string, error) {
Expand Down
81 changes: 80 additions & 1 deletion cmd/kosli/apiKey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ func TestPrintApiKeysAsTable(t *testing.T) {
require.Contains(t, out, "sk_two")
}

func TestPrintApiKeyMetadataAsTable(t *testing.T) {
// The get endpoint returns key metadata only (no secret key value), with
// timestamps as floating-point epoch seconds.
raw := `{"id":"key-1","description":"ci key","created_at":1780584129.6878593,"expires_at":0,"last_used_at":0}`

var buf bytes.Buffer
err := printApiKeyMetadataAsTable(raw, &buf, 0)
require.NoError(t, err)

out := buf.String()
require.Contains(t, out, "key-1")
require.Contains(t, out, "ci key")
// expires_at and last_used_at of 0 mean "never" and must render as N/A,
// not epoch zero (1970)
require.Regexp(t, `Expires At:\s+N/A`, out)
require.Regexp(t, `Last Used:\s+N/A`, out)
require.NotContains(t, out, "1970")
}

func TestPrintApiKeysListAsTable(t *testing.T) {
// The list endpoint returns key metadata only (no secret key value).
raw := `[{"id":"key-1","description":"first","created_at":1780584129.5,"expires_at":0,"last_used_at":0},` +
Expand Down Expand Up @@ -261,8 +280,33 @@ func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() {
runTestCmd(suite.T(), tests)
}

func (suite *ApiKeyCommandTestSuite) TestGetApiKeyCmd() {
tests := []cmdTestCase{
{
wantError: true,
name: "get fails when --service-account is missing",
cmd: "get api-key key-123" + suite.defaultKosliArguments,
golden: "Error: required flag(s) \"service-account\" not set\n",
},
{
wantError: true,
name: "get fails when KEY-ID argument is missing",
cmd: "get api-key --service-account test-sa" + suite.defaultKosliArguments,
golden: "Error: accepts 1 arg(s), received 0\n",
},
{
wantError: true,
name: "get fails when more than one KEY-ID argument is given",
cmd: "get api-key key-1 key-2 --service-account test-sa" + suite.defaultKosliArguments,
golden: "Error: accepts 1 arg(s), received 2\n",
},
}

runTestCmd(suite.T(), tests)
}

// TestApiKeysSuccessOutput stubs successful (2xx) API responses to verify that
// create/list/rotate render the server's response on the happy path.
// create/list/rotate/get render the server's response on the happy path.
func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() {
fake := httpfake.New()
defer fake.Close()
Expand All @@ -282,9 +326,34 @@ func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() {
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate").
Reply(201).
BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json"))
fake.NewHandler().
Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/id-1").
Reply(200).
BodyString(apiKeyFixture(suite.T(), "api_key.json"))

args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
{
wantError: false,
name: "get prints the key metadata as json",
cmd: "get api-key id-1 -s test-sa --output json" + args,
goldenJson: []jsonCheck{
{Path: "id", Want: "id-1"},
{Path: "description", Want: "ci"},
},
},
{
wantError: false,
name: "get renders the key metadata as a table by default",
cmd: "get api-key id-1 -s test-sa" + args,
goldenRegex: `(?s)ID:\s+id-1.*Description:\s+ci.*Expires At:\s+N/A.*Last Used:\s+N/A`,
},
{
wantError: false,
name: "the get api-key alias (ak) and -s shorthand work",
cmd: "get ak id-1 -s test-sa --output json" + args,
goldenRegex: `id-1`,
},
{
wantError: false,
name: "create prints the new key value",
Expand Down Expand Up @@ -408,6 +477,10 @@ func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() {
Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys").
Reply(403).
BodyString(apiKeyFixture(suite.T(), "error_forbidden.json"))
fake.NewHandler().
Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key").
Reply(404).
BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json"))

args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
Expand All @@ -429,6 +502,12 @@ func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() {
cmd: "list api-keys --service-account missing-sa" + args,
goldenRegex: `Error: You don't have permission to access this resource`,
},
{
wantError: true,
name: "get surfaces a 404 from the API as an error",
cmd: "get api-key missing-key --service-account test-sa" + args,
goldenRegex: `Error: API key not found`,
},
}

runTestCmd(suite.T(), tests)
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func newGetCmd(out io.Writer) *cobra.Command {

// Add subcommands
cmd.AddCommand(
newGetApiKeyCmd(out),
newGetApprovalCmd(out),
newGetArtifactCmd(out),
newGetEnvironmentCmd(out),
Expand Down
85 changes: 85 additions & 0 deletions cmd/kosli/getApiKey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"io"
"net/http"
"net/url"

"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

const getApiKeyShortDesc = `Get an API key's metadata for a service account.`

const getApiKeyLongDesc = getApiKeyShortDesc + `

Only the metadata of the API key is returned; the key value itself is never
returned (it is only shown once, at creation or rotation time).`

const getApiKeyExample = `
# get the metadata of an API key:
kosli get api-key yourApiKeyID \
--service-account yourServiceAccountName \
--api-token yourAPIToken \
--org yourOrgName
`

type getApiKeyOptions struct {
serviceAccount string
output string
}

func newGetApiKeyCmd(out io.Writer) *cobra.Command {
o := new(getApiKeyOptions)
cmd := &cobra.Command{
Use: "api-key KEY-ID",
Aliases: []string{"ak"},
Short: getApiKeyShortDesc,
Long: getApiKeyLongDesc,
Example: getApiKeyExample,
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(out, args)
},
}

cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag)
cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)

err := RequireFlags(cmd, []string{"service-account"})
if err != nil {
logger.Error("failed to configure required flags: %v", err)
}

return cmd
}

func (o *getApiKeyOptions) run(out io.Writer, args []string) error {
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys", args[0])
if err != nil {
return err
}

reqParams := &requests.RequestParams{
Method: http.MethodGet,
URL: url,
Token: global.ApiToken,
}
response, err := kosliClient.Do(reqParams)
if err != nil {
return err
}

return output.FormattedPrint(response.Body, o.output, out, 0,
map[string]output.FormatOutputFunc{
"table": printApiKeyMetadataAsTable,
"json": output.PrintJson,
})
}
10 changes: 1 addition & 9 deletions cmd/kosli/listApiKeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,7 @@ func printApiKeysListAsTable(raw string, out io.Writer, page int) error {
header := []string{"ID", "DESCRIPTION", "CREATED", "EXPIRES", "LAST USED"}
rows := []string{}
for _, key := range keys {
createdAt, err := formattedTimestamp(key.CreatedAt, false)
if err != nil {
return err
}
expiresAt, err := optionalTimestamp(key.ExpiresAt)
if err != nil {
return err
}
lastUsedAt, err := optionalTimestamp(key.LastUsedAt)
createdAt, expiresAt, lastUsedAt, err := apiKeyMetadataTimestamps(key)
if err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/testdata/service-account/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ response contract lives in one place.
| `created_api_key.json` | `POST .../{name}/api-keys` → `201` (create) |
| `rotated_api_key.json` | `POST .../{name}/api-keys/{key_id}/rotate` → `201` (rotate; includes `grace_period_expires_at`) |
| `listed_api_keys.json` | `GET .../{name}/api-keys` → `200` (list) |
| `api_key.json` | `GET .../{name}/api-keys/{key_id}` → `200` (get; metadata only) |
| `revoke_success.json` | `DELETE .../{name}/api-keys/{key_id}` → `200` (bare string) |

### Service account management endpoints
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/testdata/service-account/api_key.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id":"id-1","description":"ci","created_at":1780584129.5,"expires_at":0,"last_used_at":0}
Loading