diff --git a/internal/cmd/db_invalidatetokens.go b/internal/cmd/db_invalidatetokens.go index 8f162e48..99ea46c7 100644 --- a/internal/cmd/db_invalidatetokens.go +++ b/internal/cmd/db_invalidatetokens.go @@ -34,16 +34,15 @@ var dbInvalidateTokensCmd = &cobra.Command{ return err } - if database.Group != "" && database.Version != "tech-preview" { - return fmt.Errorf("database %s is part of group %s, use %s instead", internal.Emph(name), internal.Emph(database.Group), internal.Emph("turso group tokens invalidate ")) - } - if yesFlag { return rotateAndNotify(client, database) } - fmt.Printf("To invalidate %s database tokens, all its replicas must be restarted.\n", internal.Emph(name)) - fmt.Printf("All your active connections to the DB will be dropped and there will be a short downtime.\n\n") + fmt.Printf("This will invalidate existing database-scoped tokens for %s. Clients using them will need new tokens.\n", internal.Emph(name)) + if database.Group != "" { + fmt.Println("Group tokens will not be invalidated.") + } + fmt.Println() ok, err := promptConfirmation("Are you sure you want to do this?") if err != nil { @@ -76,8 +75,5 @@ func rotateAndNotify(turso *turso.Client, database turso.Database) error { func rotate(turso *turso.Client, database turso.Database) error { invalidateDbTokenCache() settings.PersistChanges() - if database.Group != "" && database.Version != "tech-preview" { - return turso.Groups.Rotate(database.Group) - } return turso.Databases.Rotate(database.Name) } diff --git a/internal/cmd/db_invalidatetokens_test.go b/internal/cmd/db_invalidatetokens_test.go new file mode 100644 index 00000000..8cd7412d --- /dev/null +++ b/internal/cmd/db_invalidatetokens_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/tursodatabase/turso-cli/internal/settings" + "github.com/tursodatabase/turso-cli/internal/turso" +) + +func TestInvalidateGroupedDatabaseTokensCommand(t *testing.T) { + config, err := settings.ReadSettings() + if err != nil { + t.Fatal(err) + } + previousOrg, previousURL, previousYes := config.Organization(), viper.GetString("baseURL"), yesFlag + t.Cleanup(func() { + config.SetOrganization(previousOrg) + viper.Set("baseURL", previousURL) + yesFlag = previousYes + }) + config.SetOrganization("org") + yesFlag = true + t.Setenv(ENV_ACCESS_TOKEN, "test-token") + + rotations := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/organizations/org/databases": + w.Write([]byte(`{"databases":[{"name":"db","group":"group","version":"v1"}]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/organizations/org/databases/db/auth/rotate": + rotations++ + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + viper.Set("baseURL", server.URL) + + if err := dbInvalidateTokensCmd.RunE(dbInvalidateTokensCmd, []string{"db"}); err != nil { + t.Fatal(err) + } + if rotations != 1 { + t.Errorf("database rotations = %d, want 1", rotations) + } +} + +func TestRotateDatabaseTokens(t *testing.T) { + for _, tc := range []struct { + name string + database turso.Database + status int + wantErr string + }{ + {"grouped database", turso.Database{Name: "db", Group: "group", Version: "v1"}, http.StatusOK, ""}, + {"tech-preview grouped database", turso.Database{Name: "db", Group: "group", Version: "tech-preview"}, http.StatusOK, ""}, + {"ungrouped database", turso.Database{Name: "db"}, http.StatusOK, ""}, + {"legacy dedicated grouped database", turso.Database{Name: "db", Group: "group", Version: "v1"}, http.StatusBadRequest, "cannot rotate credentials for a single database in a group"}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodPost || r.URL.Path != "/v1/organizations/org/databases/db/auth/rotate" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(tc.status) + if tc.status != http.StatusOK { + w.Write([]byte(`{"error":"cannot rotate credentials for a single database in a group"}`)) + } + })) + defer server.Close() + base, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + err = rotate(turso.New(base, "token", "dev", "org"), tc.database) + if requests != 1 { + t.Errorf("requests = %d, want 1", requests) + } + if tc.wantErr == "" { + if err != nil { + t.Fatalf("rotate: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("rotate error = %v, want %q", err, tc.wantErr) + } + }) + } +} diff --git a/internal/cmd/group_tokens.go b/internal/cmd/group_tokens.go index e3d6daef..83b6ec46 100644 --- a/internal/cmd/group_tokens.go +++ b/internal/cmd/group_tokens.go @@ -24,12 +24,12 @@ var groupTokensCmd = &cobra.Command{ func init() { groupTokensCmd.AddCommand(groupTokensInvalidateCmd) - flags.AddYes(groupTokensInvalidateCmd, "Confirms the invalidation of the credentials of the group and all its databases") + flags.AddYes(groupTokensInvalidateCmd, "Confirms the rotation of group credentials") } var groupTokensInvalidateCmd = &cobra.Command{ Use: "invalidate ", - Short: "Rotates the keys used to create and verify database tokens, invalidating all existing tokens invalid for the group.", + Short: "Rotates group credentials; database-scoped tokens may need separate invalidation", Args: cobra.ExactArgs(1), ValidArgsFunction: groupArg, RunE: func(cmd *cobra.Command, args []string) error { @@ -49,8 +49,8 @@ var groupTokensInvalidateCmd = &cobra.Command{ return rotateGroup(client, group) } - fmt.Printf("To invalidate tokens for group %s, tokens from all its databases will be invalidated.\n", internal.Emph(name)) - fmt.Printf("All your active connections to the databases in that group will be dropped and there will be a short downtime.\n\n") + fmt.Printf("This will rotate credentials for group %s; this invalidates group tokens but not necessarily database-scoped tokens.\n", internal.Emph(name)) + fmt.Printf("To invalidate database-scoped tokens, run %s for each database.\n\n", internal.Emph("turso db tokens invalidate ")) ok, err := promptConfirmation("Are you sure you want to do this?") if err != nil { @@ -78,8 +78,9 @@ func rotateGroup(turso *turso.Client, group turso.Group) error { } s.Stop() - fmt.Printf("✔ Success! Tokens invalidated successfully.\n\n") - fmt.Printf("Run %s to get a new one.\n", internal.Emph("turso group tokens create ")) + fmt.Printf("✔ Success! Group credentials rotated. This invalidates group tokens but not necessarily database-scoped tokens.\n\n") + fmt.Printf("Run %s to get a new group token.\n", internal.Emph("turso group tokens create ")) + fmt.Printf("To invalidate database-scoped tokens, run %s for each database.\n", internal.Emph("turso db tokens invalidate ")) return nil }