-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): destructive-command tiers — confirm + interactive-only deletes #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
joshdholtz
wants to merge
6
commits into
main
Choose a base branch
from
dx-948-destructive-approval-evals
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c851f12
test(cli): drift guard that destructive commands require confirmation…
joshdholtz a845279
test(cli): drive customer grant and transfer through the behavioral g…
joshdholtz 6de8e2d
test(cli): drop the AST guard, keep the behavioral destructive-comman…
joshdholtz 95f65c3
feat(cli): make the irreversible deletes interactive-only
joshdholtz c49fa9b
feat(cli): products + packages delete are interactive-only too
joshdholtz 9fc277a
fix(cli): interactive-only error covers the non-TTY case
joshdholtz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| package cli_test | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/revenuecat/cli/internal/config" | ||
| ) | ||
|
|
||
| // Every command that removes or mutates real state must refuse to act under | ||
| // --no-input unless --yes is passed, and --yes must let it through. This drives | ||
| // the real commands; a new destructive command is covered by adding it here. | ||
| // | ||
| // The fake server allows read-only GET preflight (some commands fetch state to | ||
| // decide whether extra confirmation is needed) but fails the test on any write, | ||
| // proving nothing is destroyed before consent. | ||
| func TestDestructiveCommands_RefuseUnderNoInputWithoutYes(t *testing.T) { | ||
| // The most destructive, irreversible commands are interactive-only and are | ||
| // covered separately below; --yes bypasses confirmation for everything here. | ||
| commands := [][]string{ | ||
| {"products", "push", "prod_x"}, | ||
| {"paywalls", "publish", "pw_x"}, | ||
| {"paywalls", "unpublish", "pw_x"}, | ||
| {"webhooks", "delete", "wh_x"}, | ||
| {"purchases", "refund", "txn_x"}, | ||
| {"subscriptions", "cancel", "sub_x"}, | ||
| {"subscriptions", "refund", "sub_x"}, | ||
| {"products", "store", "discard", "plan_x"}, | ||
| {"customer", "revoke", "cust_x", "ent_x"}, | ||
| {"customer", "grant", "cust_x", "ent_x", "--duration", "monthly"}, | ||
| {"customer", "transfer", "cust_x", "--to", "cust_y"}, | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| for _, args := range commands { | ||
| t.Run(strings.Join(args, " "), func(t *testing.T) { | ||
| configDir := t.TempDir() | ||
| t.Setenv("RC_CONFIG_DIR", configDir) | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodGet { | ||
| t.Errorf("%s %s: state-changing call reached the network before confirmation", r.Method, r.URL.Path) | ||
| http.Error(w, "unexpected write before confirmation", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{}`)) | ||
| })) | ||
| t.Cleanup(server.Close) | ||
| if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_test", BaseURL: server.URL}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| runArgs := append(append([]string{}, args...), "--no-input") | ||
| _, _, err := runCmdInConfigDir(t, configDir, runArgs...) | ||
| if err == nil { | ||
| t.Fatal("want refusal under --no-input without --yes") | ||
| } | ||
| if !strings.Contains(err.Error(), "pass --yes") { | ||
| t.Fatalf("want the confirmation gate error, got: %v", err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestDestructiveCommand_YesBypassesConfirmation(t *testing.T) { | ||
| configDir := t.TempDir() | ||
| t.Setenv("RC_CONFIG_DIR", configDir) | ||
| var deleted bool | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| switch { | ||
| case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/webhooks/wh_x"): | ||
| deleted = true | ||
| w.WriteHeader(http.StatusOK) | ||
| _, _ = w.Write([]byte(`{}`)) | ||
| case r.Method == http.MethodGet: | ||
| w.Header().Set("Content-Type", "application/json") | ||
| _, _ = w.Write([]byte(`{}`)) | ||
| default: | ||
| http.Error(w, "unexpected request", http.StatusNotFound) | ||
| } | ||
| })) | ||
| t.Cleanup(server.Close) | ||
| if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_test", BaseURL: server.URL}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| _, errb, err := runCmdInConfigDir(t, configDir, "webhooks", "delete", "wh_x", "--yes", "--no-input") | ||
| if err != nil { | ||
| t.Fatalf("--yes should let the delete proceed: %v\nstderr: %s", err, errb) | ||
| } | ||
| if !deleted { | ||
| t.Fatal("--yes did not let the command reach the delete call") | ||
| } | ||
| } | ||
|
|
||
| // The irreversible, customer-facing deletes are interactive-only: --yes does NOT | ||
| // buy a way through, and --json/--no-input are refused outright so automation | ||
| // can't fire them. A new human-only command is covered by adding it here. | ||
| func TestInteractiveOnlyCommands_RefuseEvenWithYes(t *testing.T) { | ||
| commands := [][]string{ | ||
| {"apps", "delete", "app_x"}, | ||
| {"paywalls", "delete", "pw_x"}, | ||
| {"offerings", "delete", "ofrng_x"}, | ||
| {"entitlements", "delete", "ent_x"}, | ||
| {"products", "delete", "prod_x"}, | ||
| {"packages", "delete", "pkg_x"}, | ||
| } | ||
| for _, args := range commands { | ||
| t.Run(strings.Join(args, " "), func(t *testing.T) { | ||
| configDir := t.TempDir() | ||
| t.Setenv("RC_CONFIG_DIR", configDir) | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| t.Errorf("%s %s: interactive-only command reached the network", r.Method, r.URL.Path) | ||
| http.Error(w, "must not touch the network", http.StatusInternalServerError) | ||
| })) | ||
| t.Cleanup(server.Close) | ||
| if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_test", BaseURL: server.URL}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| // --yes present on purpose: it must NOT bypass the interactive gate. | ||
| runArgs := append(append([]string{}, args...), "--yes", "--no-input") | ||
| _, _, err := runCmdInConfigDir(t, configDir, runArgs...) | ||
| if err == nil { | ||
| t.Fatal("want refusal: interactive-only even with --yes") | ||
| } | ||
| if !strings.Contains(err.Error(), "interactive-only") { | ||
| t.Fatalf("want the interactive-only gate error, got: %v", err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,64 +1,35 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestPaywallsDeleteRequiresForceForAttachedOrPublished(t *testing.T) { | ||
| paywalls := map[string]string{ | ||
| "pw_attached": `{"object":"paywall","id":"pw_attached","name":"Hero","offering_id":"ofrng_x","created_at":1700000000000,"published_at":null}`, | ||
| "pw_published": `{"object":"paywall","id":"pw_published","name":"Live","offering_id":"ofrng_live","created_at":1700000000000,"published_at":1700000000000}`, | ||
| "pw_standalone": `{"object":"paywall","id":"pw_standalone","created_at":1700000000000,"published_at":null}`, | ||
| } | ||
| var deletedPaths []string | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| id := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] | ||
| switch r.Method { | ||
| case http.MethodGet: | ||
| io.WriteString(w, paywalls[id]) | ||
| case http.MethodDelete: | ||
| deletedPaths = append(deletedPaths, r.URL.Path) | ||
| io.WriteString(w, `{}`) | ||
| default: | ||
| w.WriteHeader(http.StatusNotFound) | ||
| } | ||
| })) | ||
| defer server.Close() | ||
| t.Setenv("RC_CONFIG_DIR", t.TempDir()) | ||
| t.Setenv("RC_BASE_URL", server.URL) | ||
|
|
||
| del := func(args ...string) error { | ||
| root := NewRootCmd("test") | ||
| root.SetOut(io.Discard) | ||
| root.SetErr(&bytes.Buffer{}) | ||
| root.SetArgs(append([]string{"paywalls", "delete"}, append(args, "--yes", "--api-key", "sk_test", "--project-id", "proj")...)) | ||
| return root.ExecuteContext(context.Background()) | ||
| } | ||
|
|
||
| for _, id := range []string{"pw_attached", "pw_published"} { | ||
| if err := del(id); err == nil { | ||
| t.Fatalf("%s should require --force", id) | ||
| } | ||
| } | ||
| if len(deletedPaths) != 0 { | ||
| t.Fatalf("refusals must not issue DELETE, got %v", deletedPaths) | ||
| } | ||
| "github.com/revenuecat/cli/internal/api" | ||
| ) | ||
|
|
||
| if err := del("pw_standalone"); err != nil { | ||
| t.Fatalf("standalone draft should delete without --force: %v", err) | ||
| } | ||
| if err := del("pw_attached", "--force"); err != nil { | ||
| t.Fatalf("--force should delete attached paywall: %v", err) | ||
| // paywalls delete is interactive-only (its refusal to run under automation is | ||
| // covered by the destructive-command guard), so the force-check is exercised | ||
| // directly here: a published or attached paywall needs --force, a standalone | ||
| // draft does not. | ||
| func TestCheckPaywallDeletable(t *testing.T) { | ||
| published := api.Millis(1700000000000) | ||
| tests := []struct { | ||
| name string | ||
| paywall *api.Paywall | ||
| force bool | ||
| wantErr bool | ||
| }{ | ||
| {name: "standalone draft deletes", paywall: &api.Paywall{ID: "pw_standalone"}}, | ||
| {name: "published needs force", paywall: &api.Paywall{ID: "pw_pub", PublishedAt: &published}, wantErr: true}, | ||
| {name: "attached needs force", paywall: &api.Paywall{ID: "pw_att", OfferingID: "ofrng_x"}, wantErr: true}, | ||
| {name: "published with force deletes", paywall: &api.Paywall{ID: "pw_pub", PublishedAt: &published}, force: true}, | ||
| {name: "attached with force deletes", paywall: &api.Paywall{ID: "pw_att", OfferingID: "ofrng_x"}, force: true}, | ||
| } | ||
| want := []string{"/projects/proj/paywalls/pw_standalone", "/projects/proj/paywalls/pw_attached"} | ||
| if len(deletedPaths) != 2 || deletedPaths[0] != want[0] || deletedPaths[1] != want[1] { | ||
| t.Fatalf("DELETE paths = %v, want %v", deletedPaths, want) | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| err := checkPaywallDeletable(tt.paywall, tt.paywall.ID, tt.force) | ||
| if (err != nil) != tt.wantErr { | ||
| t.Fatalf("checkPaywallDeletable err = %v, wantErr %v", err, tt.wantErr) | ||
| } | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.