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
172 changes: 172 additions & 0 deletions cmd/volcano/grant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package main

import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/Kong/volcano-cli/internal/api"
rootcmd "github.com/Kong/volcano-cli/internal/cmd/root"
cliconfig "github.com/Kong/volcano-cli/internal/config"
cliruntime "github.com/Kong/volcano-cli/internal/runtime"
)

// testGrant is a delegated grant as a worker gets it. Every test here checks
// that it never shows up in the output.
const testGrant = cliconfig.DelegatedGrantPrefix + "grant-secret-value"

// grantRun is one command run the way a Builder worker runs the CLI.
type grantRun struct {
code int
stdout string
stderr string
requests []string
auth []string
}

// runAsWorker runs the real command tree with the worker's environment: the
// grant in VOLCANO_TOKEN, the API in VOLCANO_API_URL, the project from the
// directory's link file, telemetry off and no prompts. No config loader is
// injected, so the CLI reads its configuration the way it does in the VM.
func runAsWorker(t *testing.T, handler http.HandlerFunc, args ...string) grantRun {
t.Helper()
resetInstructions(t)
api.ResetLastRefusalForTest()
t.Cleanup(api.ResetLastRefusalForTest)

var mu sync.Mutex
result := grantRun{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
result.requests = append(result.requests, r.Method+" "+r.URL.Path)
result.auth = append(result.auth, r.Header.Get("Authorization"))
mu.Unlock()
handler(w, r)
}))
t.Cleanup(server.Close)

home := t.TempDir()
workspace := filepath.Join(home, "workspace", "app")
require.NoError(t, os.MkdirAll(filepath.Join(workspace, ".volcano"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workspace, ".volcano", "project.json"),
[]byte(`{"project_id":"`+mainTestProjectAlpha+`"}`), 0o644))
t.Setenv("HOME", home)
t.Chdir(workspace)
t.Setenv("VOLCANO_TOKEN", testGrant)
t.Setenv("VOLCANO_API_URL", server.URL)
t.Setenv("VOLCANO_PROJECT_ID", "")
t.Setenv("VOLCANO_TELEMETRY_DISABLED", "1")
t.Setenv("VOLCANO_NONINTERACTIVE", "1")

deps := cliruntime.Deps{HTTPClient: server.Client()}
root := rootcmd.New(deps)
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs(args)
result.code = run(root, deps)
result.stdout, result.stderr = stdout.String(), stderr.String()

assert.NotContains(t, result.stdout, "grant-secret-value", "the grant was printed")
assert.NotContains(t, result.stderr, "grant-secret-value", "the grant was printed")
for _, request := range result.requests {
assert.NotContains(t, request, "/cli/telemetry", "the grant reached telemetry")
assert.NotContains(t, request, "/service-keys", "a grant must never cause a service key")
assert.NotContains(t, request, "/user", "a grant must never reach an account route")
}
return result
}

// projectFunctionsPath is the path `cloud functions list` reads.
const projectFunctionsPath = "/projects/" + mainTestProjectAlpha + "/functions"

func TestGrantCallsProjectRoutesWithTheGrant(t *testing.T) {
run := runAsWorker(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == projectFunctionsPath {
respondExitTestJSON(w, http.StatusOK, `{"data":[],"has_more":false,"page":1,"limit":100,"total":0}`)
return
}
respondExitTestJSON(w, http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`)
}, "cloud", "functions", "list")

assert.Equal(t, 0, run.code, run.stderr)
assert.Equal(t, []string{"GET " + projectFunctionsPath}, run.requests)
assert.Equal(t, []string{"Bearer " + testGrant}, run.auth)
assert.NotContains(t, run.stderr, "VOLCANO_PROJECT_ID is deprecated", "the link file chose the project")
}

// Hosting's refusals keep their exit codes and messages. Only a spent budget
// gets its own code, because a new grant is the fix.
func TestGrantRefusalsExitCodes(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
code int
want string
}{
{
"route not allowed", http.StatusForbidden,
`{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`,
exitPermissionDenied, "delegated grants cannot be used on this endpoint",
},
{
"permission missing", http.StatusForbidden,
`{"error":"the delegated grant does not carry the permission this endpoint needs","code":"grant_permission_missing"}`,
exitPermissionDenied, "the delegated grant does not carry the permission this endpoint needs",
},
{
"budget exhausted", http.StatusTooManyRequests,
`{"error":"the delegated grant has no mutations left; ask the app for a new grant","code":"grant_budget_exhausted"}`,
exitGrantBudgetExhausted, "the delegated grant has no mutations left; ask the app for a new grant",
},
// Any other 429, such as the per-grant rate limit, is not the budget.
{
"rate limited", http.StatusTooManyRequests,
`{"error":"rate limit exceeded","code":"rate_limit_exceeded"}`, exitFailure, "rate limit exceeded",
},
{
"expired or revoked", http.StatusUnauthorized,
`{"error":"invalid or expired token"}`, exitNotAuthenticated, "invalid or expired token",
},
} {
t.Run(tc.name, func(t *testing.T) {
run := runAsWorker(t, func(w http.ResponseWriter, _ *http.Request) {
respondExitTestJSON(w, tc.status, tc.body)
}, "cloud", "functions", "list")

assert.Equal(t, tc.code, run.code, run.stderr)
assert.Contains(t, run.stderr, tc.want)
})
}
}

// A grant was an unknown prefix, so an account token, before the CLI knew it.
// Account commands now refuse it before any request, as they refuse a pt-.
func TestGrantIsRefusedOnAccountCommandsBeforeAnyRequest(t *testing.T) {
for _, args := range [][]string{
{"projects", "list"},
{"cloud", "functions", "list", "--project", "some-project-name"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
run := runAsWorker(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("an account call reached the API: %s %s", r.Method, r.URL.Path)
respondExitTestJSON(w, http.StatusForbidden, `{"error":"forbidden"}`)
}, args...)

assert.Equal(t, exitPermissionDenied, run.code, run.stderr)
assert.Contains(t, run.stderr, "this command needs an account token")
assert.Contains(t, run.stderr, "the current credential is a delegated grant (pg-)")
assert.NotContains(t, run.stderr, "project access token")
assert.Empty(t, run.requests)
})
}
}
5 changes: 5 additions & 0 deletions cmd/volcano/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const (
exitNoProject = 3
exitNotFound = 4
exitPermissionDenied = 5
// exitGrantBudgetExhausted is the API's 429 grant_budget_exhausted: a
// delegated grant used all of its mutations, and a new grant is the fix.
exitGrantBudgetExhausted = 6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ P3: Docs for exit code 6 and delegated grants were dropped in the merge, and no volcano-docs PR is linked

After the merge of main (#489 moved published docs to Kong/volcano-docs), this diff no longer touches docs/scripting.md, docs/authentication.md, docs/feedback.md, docs/lavaflow.md, docs/durable-functions.md, docs/domains.md or docs/git.md. The PR description still lists them under Summary, and its Tracking section links no Kong/volcano-docs PR. AGENTS.md says a change to user-facing behavior needs a companion PR in Kong/volcano-docs that updates content/cli, and that the two PRs must be linked. Without one, the published scripting page will not list exit code 6, which the Builder worker relies on. The authentication, feedback, LavaFlow and approvals pages also won't mention delegated grants. Fix: move those edits into a Kong/volcano-docs PR under content/cli, following its spec/markdown-format.md, link it here, and update the description's docs bullet.

Found by claude-opus-5-5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ P3: Link the required companion documentation PR

AGENTS.md requires a cross-linked Kong/volcano-docs PR for user-facing changes. The supplied description and comments contain no companion link and still cite removed local docs, including docs/scripting.md. Open or link the companion PR updating content/cli for grant handling and exit code 6.

Found by gpt-6.1-sol

)

func main() {
Expand Down Expand Up @@ -187,6 +190,8 @@ func printProjectEnvDeprecation(w io.Writer, used bool) {
func exitCode(err error) int {
status := api.Status(err)
switch {
case api.GrantBudgetExhausted(err):
return exitGrantBudgetExhausted
case errors.Is(err, config.ErrNotAuthenticated), status == http.StatusUnauthorized:
return exitNotAuthenticated
case errors.Is(err, config.ErrNoProjectSelected), errors.Is(err, config.ErrNoProjectSelectedForProjectToken):
Expand Down
2 changes: 2 additions & 0 deletions cmd/volcano/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ func TestRunMapsErrorsToStableExitCodes(t *testing.T) {
{name: "missing resource", err: fmt.Errorf("get: %w", &api.Error{StatusCode: http.StatusNotFound}), want: exitNotFound},
{name: "refused", err: fmt.Errorf("delete: %w", &api.Error{StatusCode: http.StatusForbidden}), want: exitPermissionDenied},
{name: "account token required", err: cliconfig.ErrAccountTokenRequired, want: exitPermissionDenied},
{name: "account token required, not a grant", err: cliconfig.ErrAccountTokenRequiredForGrant, want: exitPermissionDenied},
{name: "no project for a grant", err: cliconfig.ErrNoProjectSelectedForGrant, want: exitNoProject},
{name: "unknown resource", err: fmt.Errorf("frontend %q %w", "web", api.ErrNotFound), want: exitNotFound},
{name: "rewritten 404", err: api.WithCause(errors.New("no such thing"), &api.Error{StatusCode: http.StatusNotFound}), want: exitNotFound},
{name: "rewritten refusal", err: api.WithCause(errors.New("ask a person"), cliconfig.ErrAccountTokenRequired), want: exitPermissionDenied},
Expand Down
17 changes: 17 additions & 0 deletions internal/api/grant_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package api

import (
"errors"
"net/http"
)

// GrantBudgetExhaustedCode is the API's 429 code for a change made after a
// delegated grant used all of its mutations. A new grant gets a new budget.
const GrantBudgetExhaustedCode = "grant_budget_exhausted"

// GrantBudgetExhausted reports whether err is the API's 429 for a delegated
// grant with no mutations left.
func GrantBudgetExhausted(err error) bool {
apiErr, ok := errors.AsType[*Error](err)
return ok && apiErr.StatusCode == http.StatusTooManyRequests && apiErr.Code == GrantBudgetExhaustedCode
}
6 changes: 3 additions & 3 deletions internal/cmd/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,9 @@ func ensureSession(ctx context.Context, opts options, sessions clisession.Factor
}

if token := cfg.Token(); token != "" && !opts.signup && !refused {
// A project access token can't be checked without a project; the
// project lookup below reports it if it is wrong.
if config.IsProjectToken(token) {
// A project access token or a delegated grant can't be checked without a
// project; the project lookup below reports it if it is wrong.
if config.IsProjectBound(token) {
return nil
}
client, err := sessions.APIClient(sessions.APIURL(cfg), token)
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/durable/approvals/approvals.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ one with ctx.waitForApproval and suspends until a person approves or denies it,
or until its timeout passes. Either way the workflow resumes with the outcome.

Anyone with access to the project can list approvals and read their stats.
Only a person can decide one: approve and deny refuse a project access token.`,
Only a person can decide one: approve and deny refuse a project access token or a delegated grant.`,
}
cmd.AddCommand(newList(deps))
cmd.AddCommand(newGet(deps))
Expand Down
24 changes: 17 additions & 7 deletions internal/cmd/durable/approvals/approvals_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -867,15 +867,25 @@ func TestApprovalsDecideRefusesAProjectAccessToken(t *testing.T) {
}
}

// A credential the CLI knows is a project access token is refused before the
// prompt and before the approval is read: confirming a decision the API will
// refuse wastes the person's yes, and an approval that does not exist would
// otherwise answer with a 404 instead of saying who can decide.
// A credential the CLI knows is a project access token or a delegated grant is
// refused before the prompt and before the approval is read: confirming a
// decision the API will refuse wastes the person's yes, and an approval that
// does not exist would otherwise answer with a 404 instead of saying who can
// decide.
func TestApprovalsDecideRefusesAProjectTokenBeforeAnyRequest(t *testing.T) {
for _, args := range [][]string{{"approve", approvalID}, {"deny", approvalID, "--yes"}} {
t.Run(args[0], func(t *testing.T) {
for _, tc := range []struct{ token, command string }{
{"PT-approvals-test", "approve"},
{"PT-approvals-test", "deny"},
{"pg-approvals-test", "approve"},
{"pg-approvals-test", "deny"},
} {
args := []string{"approve", approvalID}
if tc.command == "deny" {
args = []string{"deny", approvalID, "--yes"}
}
t.Run(tc.token[:3]+tc.command, func(t *testing.T) {
setApprovalsTestHome(t)
t.Setenv("VOLCANO_TOKEN", "PT-approvals-test")
t.Setenv("VOLCANO_TOKEN", tc.token)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("a project access token's decision must not reach the API: %s %s", r.Method, r.URL.Path)
respondJSON(t, w, http.StatusNotFound, map[string]any{"error": "not found"})
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/durable/approvals/approve.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ was denied, expired, or cancelled cannot be approved; the command says which
and exits non-zero.

Only a person can decide: log in with 'volcano login'. A project access token
is refused.
or a delegated grant is refused.

By default this command prompts for confirmation, showing the approval's
title. Use --yes to skip the prompt.`,
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/durable/approvals/deny.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ approved, expired, or cancelled cannot be denied; the command says which and
exits non-zero.

Only a person can decide: log in with 'volcano login'. A project access token
is refused.
or a delegated grant is refused.

By default this command prompts for confirmation, showing the approval's
title. Use --yes to skip the prompt.`,
Expand Down
19 changes: 12 additions & 7 deletions internal/cmd/feedback/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,14 @@ carries the selected project ID and this machine's telemetry ID, and is linked
to your account. With telemetry off and feedback turned on with volcano
feedback enable, it carries neither and is not linked to your account. Every
report is sent with your login, which the server checks, so sending one
requires volcano login. A project access token cannot send reports.
requires volcano login. A project access token or a delegated grant cannot
send reports.

A report is not sent, and the command exits 0 with no output other than the
first-run notice when this run shows it, while feedback is off, when you are
not logged in or use a project access token, or before the first-run notice
has been shown. --dry-run says whether a report would be sent, and volcano
feedback status says why not.
not logged in or use a project access token or a delegated grant, or before
the first-run notice has been shown. --dry-run says whether a report would be
sent, and volcano feedback status says why not.

Feedback follows telemetry: turning telemetry off also turns feedback off,
unless you run volcano feedback enable. Turn it off with volcano feedback
Expand Down Expand Up @@ -127,8 +128,8 @@ disable or VOLCANO_FEEDBACK_DISABLED=1.`,
return nil
}
// The server takes reports only from a logged-in account, so a
// logged-out run, or one with a project access token, sends
// nothing, like feedback that is off.
// logged-out run, or one with a project access token or a
// delegated grant, sends nothing, like feedback that is off.
token := cfg.Token()
loggedIn := telemetry.CanSendFeedback(token)
if !loggedIn && !dryRun {
Expand Down Expand Up @@ -239,6 +240,10 @@ func printStatus(cmd *cobra.Command) error {
fmt.Fprintln(w, "Feedback is enabled, but no report is sent until you log in with `volcano login`.")
return nil
}
if config.IsDelegatedGrant(cfg.Token()) {
fmt.Fprintln(w, "Feedback is enabled, but a delegated grant cannot send reports. Log in with `volcano login`.")
return nil
}
if config.IsProjectToken(cfg.Token()) {
fmt.Fprintln(w, "Feedback is enabled, but a project access token cannot send reports. Log in with `volcano login`.")
return nil
Expand Down Expand Up @@ -338,7 +343,7 @@ func saveReport(payload []byte) error {

// printReport prints report for --dry-run, and says so when a real run would
// send nothing: feedback is off, the first-run notice has not been shown, or
// the user is logged out or uses a project access token.
// the user is logged out or uses a project access token or a delegated grant.
func printReport(cmd *cobra.Command, report feedback.Report, sends bool) error {
out, err := encodeReport(report, " ")
if err != nil {
Expand Down
33 changes: 20 additions & 13 deletions internal/cmd/feedback/feedback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,22 +309,29 @@ func TestFeedback_LoggedOutSendsNothing(t *testing.T) {
}

func TestFeedback_ProjectTokenSendsNothing(t *testing.T) {
reports := setup(t, &config.TelemetryConfig{DeviceID: "d", NoticeShown: true})
t.Setenv("VOLCANO_TOKEN", "pt-abc")
for token, credential := range map[string]string{
"pt-abc": "a project access token",
"pg-abc": "a delegated grant",
} {
t.Run(token, func(t *testing.T) {
reports := setup(t, &config.TelemetryConfig{DeviceID: "d", NoticeShown: true})
t.Setenv("VOLCANO_TOKEN", token)

stdout, stderr, err := run(t, reportArgs...)
require.NoError(t, err, "exit 0, so an agent does not retry")
assert.Empty(t, stdout)
assert.Empty(t, stderr)
assert.Empty(t, *reports)
stdout, stderr, err := run(t, reportArgs...)
require.NoError(t, err, "exit 0, so an agent does not retry")
assert.Empty(t, stdout)
assert.Empty(t, stderr)
assert.Empty(t, *reports)

stdout, _, err = run(t, "status")
require.NoError(t, err)
assert.Equal(t, "Feedback is enabled, but a project access token cannot send reports. Log in with `volcano login`.\n", stdout)
stdout, _, err = run(t, "status")
require.NoError(t, err)
assert.Equal(t, "Feedback is enabled, but "+credential+" cannot send reports. Log in with `volcano login`.\n", stdout)

_, stderr, err = run(t, append([]string{"--dry-run"}, reportArgs...)...)
require.NoError(t, err)
assert.Contains(t, stderr, "This report would not be sent.")
_, stderr, err = run(t, append([]string{"--dry-run"}, reportArgs...)...)
require.NoError(t, err)
assert.Contains(t, stderr, "This report would not be sent.")
})
}
}

func TestFeedback_SettingSurvivesLogout(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/lavaflow/disable.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func newDisable(deps cliruntime.Deps) *cobra.Command {
Short: "Turn LavaFlow off",
Long: `Turn LavaFlow off for the current project. No new run starts; past runs and
their records stay readable. Only the project's owner can, from their own
session: a project access token is refused.`,
session: a project access token or a delegated grant is refused.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
opts.deps = deps
Expand Down
Loading
Loading