From fd13e487807d132af8fef74dc455bb91da9533f2 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Mon, 26 Oct 2026 13:58:31 -0700 Subject: [PATCH 1/6] chore(public): regenerate the Public API client Picks up livekit/public-api-server#97. ProjectService.RemoveMember and WorkspaceService.DeleteMember take a confirmedNumKeys query parameter, and their responses report numKeys, the API keys the removal deleted. The client methods pass no parameters yet, so lk sends what it sent before. Generated from livekit/public-api-server@7bfa9ec8cb8c88524733fd33e611727762d8be8e --- pkg/public/members.go | 2 +- pkg/public/oapi/oapi.gen.go | 108 ++++++++++++++++++++++++++++++------ pkg/public/workspaces.go | 2 +- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/pkg/public/members.go b/pkg/public/members.go index 03b3b6a1f..c5401a721 100644 --- a/pkg/public/members.go +++ b/pkg/public/members.go @@ -60,7 +60,7 @@ func (c *Client) UpdateProjectMember(ctx context.Context, projectID, userID stri // RemoveProjectMember removes a member from a project. func (c *Client) RemoveProjectMember(ctx context.Context, projectID, userID string) error { - resp, err := c.gen.ProjectServiceRemoveMemberWithResponse(ctx, projectID, userID) + resp, err := c.gen.ProjectServiceRemoveMemberWithResponse(ctx, projectID, userID, nil) if err != nil { return err } diff --git a/pkg/public/oapi/oapi.gen.go b/pkg/public/oapi/oapi.gen.go index b08a714da..49fc7dea7 100644 --- a/pkg/public/oapi/oapi.gen.go +++ b/pkg/public/oapi/oapi.gen.go @@ -2849,7 +2849,10 @@ type LivekitPublicapiProjectsV1ProjectPreferences struct { } // LivekitPublicapiProjectsV1RemoveMemberResponse defines model for livekit.publicapi.projects.v1.RemoveMemberResponse. -type LivekitPublicapiProjectsV1RemoveMemberResponse = map[string]interface{} +type LivekitPublicapiProjectsV1RemoveMemberResponse struct { + // NumKeys Non-agent API keys the removal deleted. + NumKeys *int `json:"numKeys,omitempty"` +} // LivekitPublicapiProjectsV1SendTestWebhookResponse Reports the receiver's answer to one delivery attempt. The test is not // @@ -3156,7 +3159,10 @@ type LivekitPublicapiWorkspacesV1CreateWorkspaceResponse struct { type LivekitPublicapiWorkspacesV1DeleteInviteResponse = map[string]interface{} // LivekitPublicapiWorkspacesV1DeleteMemberResponse defines model for livekit.publicapi.workspaces.v1.DeleteMemberResponse. -type LivekitPublicapiWorkspacesV1DeleteMemberResponse = map[string]interface{} +type LivekitPublicapiWorkspacesV1DeleteMemberResponse struct { + // NumKeys Non-agent API keys the removal deleted. + NumKeys *int `json:"numKeys,omitempty"` +} // LivekitPublicapiWorkspacesV1DeleteWorkspaceResponse defines model for livekit.publicapi.workspaces.v1.DeleteWorkspaceResponse. type LivekitPublicapiWorkspacesV1DeleteWorkspaceResponse = map[string]interface{} @@ -3390,6 +3396,14 @@ type ProjectServiceGenerateKeyJSONBody struct { IsServiceAccount *bool `json:"isServiceAccount,omitempty"` } +// ProjectServiceRemoveMemberParams defines parameters for ProjectServiceRemoveMember. +type ProjectServiceRemoveMemberParams struct { + // ConfirmedNumKeys Number of the member's API keys the caller agrees to delete. Call with 0; + // if the member owns keys, the call fails with a MemberRemovalNotConfirmed + // detail, so show its num_keys to the user and call again with it. + ConfirmedNumKeys *int `form:"confirmedNumKeys,omitempty" json:"confirmedNumKeys,omitempty"` +} + // ProjectServiceUpdateMemberJSONBody defines parameters for ProjectServiceUpdateMember. type ProjectServiceUpdateMemberJSONBody struct { Role *int32 `json:"role,omitempty"` @@ -3791,6 +3805,14 @@ type WorkspaceServiceCreateInviteJSONBody struct { Role *int32 `json:"role,omitempty"` } +// WorkspaceServiceDeleteMemberParams defines parameters for WorkspaceServiceDeleteMember. +type WorkspaceServiceDeleteMemberParams struct { + // ConfirmedNumKeys Number of the member's API keys the caller agrees to delete. Call with 0; + // if the member owns keys, the call fails with a MemberRemovalNotConfirmed + // detail, so show its num_keys to the user and call again with it. + ConfirmedNumKeys *int `form:"confirmedNumKeys,omitempty" json:"confirmedNumKeys,omitempty"` +} + // WorkspaceServiceUpdateMemberJSONBody defines parameters for WorkspaceServiceUpdateMember. type WorkspaceServiceUpdateMemberJSONBody struct { Role *int32 `json:"role,omitempty"` @@ -6128,7 +6150,7 @@ type ClientInterface interface { // ProjectServiceRemoveMember RemoveMember // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). - ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) + ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) // ProjectServiceGetMember GetMember // @@ -6690,7 +6712,7 @@ type ClientInterface interface { // WorkspaceServiceDeleteMember DeleteMember // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). - WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) + WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) // WorkspaceServiceGetMember GetMember // @@ -7258,8 +7280,8 @@ func (c *Client) ProjectServiceListMembers(ctx context.Context, projectId string // ProjectServiceRemoveMember RemoveMember // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). -func (c *Client) ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewProjectServiceRemoveMemberRequest(c.Server, projectId, userId) +func (c *Client) ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProjectServiceRemoveMemberRequest(c.Server, projectId, userId, params) if err != nil { return nil, err } @@ -8498,8 +8520,8 @@ func (c *Client) WorkspaceServiceListMembers(ctx context.Context, workspaceId st // WorkspaceServiceDeleteMember DeleteMember // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). -func (c *Client) WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewWorkspaceServiceDeleteMemberRequest(c.Server, workspaceId, userId) +func (c *Client) WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWorkspaceServiceDeleteMemberRequest(c.Server, workspaceId, userId, params) if err != nil { return nil, err } @@ -9821,7 +9843,7 @@ func NewProjectServiceListMembersRequest(server string, projectId string) (*http } // NewProjectServiceRemoveMemberRequest constructs an http.Request for the ProjectServiceRemoveMember method -func NewProjectServiceRemoveMemberRequest(server string, projectId string, userId string) (*http.Request, error) { +func NewProjectServiceRemoveMemberRequest(server string, projectId string, userId string, params *ProjectServiceRemoveMemberParams) (*http.Request, error) { var err error var pathParam0 string @@ -9853,6 +9875,33 @@ func NewProjectServiceRemoveMemberRequest(server string, projectId string, userI return nil, err } + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.ConfirmedNumKeys != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "confirmedNumKeys", *params.ConfirmedNumKeys, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err @@ -12801,7 +12850,7 @@ func NewWorkspaceServiceListMembersRequest(server string, workspaceId string) (* } // NewWorkspaceServiceDeleteMemberRequest constructs an http.Request for the WorkspaceServiceDeleteMember method -func NewWorkspaceServiceDeleteMemberRequest(server string, workspaceId string, userId string) (*http.Request, error) { +func NewWorkspaceServiceDeleteMemberRequest(server string, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams) (*http.Request, error) { var err error var pathParam0 string @@ -12833,6 +12882,33 @@ func NewWorkspaceServiceDeleteMemberRequest(server string, workspaceId string, u return nil, err } + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.ConfirmedNumKeys != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "confirmedNumKeys", *params.ConfirmedNumKeys, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err @@ -13425,7 +13501,7 @@ type ClientWithResponsesInterface interface { // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). - ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) + ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) // ProjectServiceGetMemberWithResponse GetMember // @@ -14057,7 +14133,7 @@ type ClientWithResponsesInterface interface { // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). - WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) + WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) // WorkspaceServiceGetMemberWithResponse GetMember // @@ -18387,8 +18463,8 @@ func (c *ClientWithResponses) ProjectServiceListMembersWithResponse(ctx context. // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). -func (c *ClientWithResponses) ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) { - rsp, err := c.ProjectServiceRemoveMember(ctx, projectId, userId, reqEditors...) +func (c *ClientWithResponses) ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) { + rsp, err := c.ProjectServiceRemoveMember(ctx, projectId, userId, params, reqEditors...) if err != nil { return nil, err } @@ -19437,8 +19513,8 @@ func (c *ClientWithResponses) WorkspaceServiceListMembersWithResponse(ctx contex // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). -func (c *ClientWithResponses) WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) { - rsp, err := c.WorkspaceServiceDeleteMember(ctx, workspaceId, userId, reqEditors...) +func (c *ClientWithResponses) WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) { + rsp, err := c.WorkspaceServiceDeleteMember(ctx, workspaceId, userId, params, reqEditors...) if err != nil { return nil, err } diff --git a/pkg/public/workspaces.go b/pkg/public/workspaces.go index d8c3087a9..12f929c5d 100644 --- a/pkg/public/workspaces.go +++ b/pkg/public/workspaces.go @@ -196,7 +196,7 @@ func (c *Client) UpdateWorkspaceMember(ctx context.Context, workspaceID, userID // DeleteWorkspaceMember removes a member from a workspace. func (c *Client) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error { - resp, err := c.gen.WorkspaceServiceDeleteMemberWithResponse(ctx, workspaceID, userID) + resp, err := c.gen.WorkspaceServiceDeleteMemberWithResponse(ctx, workspaceID, userID, nil) if err != nil { return err } From 2684f6f8fb67ccaf3f228059ebd3467bdf922139 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Mon, 26 Oct 2026 14:00:51 -0700 Subject: [PATCH 2/6] feat(public): send and read a member removal's key count RemoveProjectMember and DeleteWorkspaceMember take confirmedNumKeys and return how many API keys the removal deleted. When the member owns keys the caller didn't confirm, or the removal would take a project's last key, the Public API refuses with a MemberRemovalNotConfirmed detail; MemberRemovalNotConfirmed reads it from the error. A count of 0 is left out of the request, which the API reads as 0. A server from before livekit/public-api-server#97 answers the parameter with a 500 (unknown field), so leaving it out keeps removals working there until #97 is deployed. The commands still confirm 0, so they send what they sent before. --- cmd/lk/project_members.go | 2 +- cmd/lk/workspace.go | 2 +- pkg/public/members.go | 70 +++++++++++++++++-- pkg/public/members_test.go | 138 +++++++++++++++++++++++++++++++++++++ pkg/public/workspaces.go | 17 +++-- 5 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 pkg/public/members_test.go diff --git a/cmd/lk/project_members.go b/cmd/lk/project_members.go index bd1b29a73..309e7a732 100644 --- a/cmd/lk/project_members.go +++ b/cmd/lk/project_members.go @@ -223,7 +223,7 @@ func removeProjectMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - if err := client.RemoveProjectMember(ctx, projectID, userID); err != nil { + if _, err := client.RemoveProjectMember(ctx, projectID, userID, 0); err != nil { return cloudAPIError(err) } return util.RenderAction(out, cmd.Bool("json"), "userId", userID, "removed", fmt.Sprintf("Removed member %s", util.Accented(userID))) diff --git a/cmd/lk/workspace.go b/cmd/lk/workspace.go index 9bd9c2397..8f85081fa 100644 --- a/cmd/lk/workspace.go +++ b/cmd/lk/workspace.go @@ -491,7 +491,7 @@ func deleteWorkspaceMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - if err := client.DeleteWorkspaceMember(ctx, wsID, userID); err != nil { + if _, err := client.DeleteWorkspaceMember(ctx, wsID, userID, 0); err != nil { return cloudAPIError(err) } return util.RenderAction(out, cmd.Bool("json"), "userId", userID, "removed", fmt.Sprintf("Removed member %s", util.Accented(userID))) diff --git a/pkg/public/members.go b/pkg/public/members.go index c5401a721..a63c441b5 100644 --- a/pkg/public/members.go +++ b/pkg/public/members.go @@ -58,13 +58,73 @@ func (c *Client) UpdateProjectMember(ctx context.Context, projectID, userID stri return requirePayload(resp.JSON200.Member, "member") } -// RemoveProjectMember removes a member from a project. -func (c *Client) RemoveProjectMember(ctx context.Context, projectID, userID string) error { - resp, err := c.gen.ProjectServiceRemoveMemberWithResponse(ctx, projectID, userID, nil) +// RemoveProjectMember removes a member from a project (from its whole +// workspace when the project isn't private), deletes the API keys they own +// there, and returns how many it deleted. confirmedNumKeys must be that count: +// call with 0, and if the member owns keys the error carries the count (see +// MemberRemovalNotConfirmed). +func (c *Client) RemoveProjectMember(ctx context.Context, projectID, userID string, confirmedNumKeys uint32) (uint32, error) { + resp, err := c.gen.ProjectServiceRemoveMemberWithResponse(ctx, projectID, userID, &oapi.ProjectServiceRemoveMemberParams{ + ConfirmedNumKeys: confirmedNumKeysParam(confirmedNumKeys), + }) if err != nil { - return err + return 0, err + } + if resp.JSON200 == nil { + return 0, responseError(resp.StatusCode(), resp.Body) + } + return numKeys(resp.JSON200.NumKeys), nil +} + +// MemberRemoval is what removing a member would do to the API keys they own. +type MemberRemoval struct { + // NumKeys is how many of the member's API keys the removal deletes. Pass + // it as confirmedNumKeys to go ahead. + NumKeys uint32 `json:"numKeys"` + // LastKeyProjectNames are the projects the removal would leave without an + // API key. Private ones the caller may not see are only counted, in + // NumPrivateLastKeyProjects. + LastKeyProjectNames []string `json:"lastKeyProjectNames"` + NumPrivateLastKeyProjects uint32 `json:"numPrivateLastKeyProjects"` +} + +// LeavesProjectWithoutKey reports whether the removal would take a project's +// last API key. The Public API refuses such a removal even when the count is +// confirmed. +func (r *MemberRemoval) LeavesProjectWithoutKey() bool { + return len(r.LastKeyProjectNames) > 0 || r.NumPrivateLastKeyProjects > 0 +} + +// MemberRemovalNotConfirmed reports whether err is the Public API's refusal of +// a member removal that didn't go ahead: the member owns API keys and the +// caller didn't confirm their number, or the removal would leave a project +// without an API key. +func MemberRemovalNotConfirmed(err error) (*MemberRemoval, bool) { + var removal MemberRemoval + if !errorDetail(err, "livekit.publicapi.common.v1.MemberRemovalNotConfirmed", &removal) { + return nil, false + } + return &removal, true +} + +// confirmedNumKeysParam leaves a count of 0 out of the request, which the API +// reads as 0. A server from before confirmedNumKeys answers the parameter with +// an unknown-field error, so leaving it out keeps removing a member who owns +// no keys working there. +func confirmedNumKeysParam(n uint32) *int { + if n == 0 { + return nil + } + return ptr(int(n)) +} + +// numKeys reads a member removal's numKeys, which the API leaves out when no +// key was deleted. +func numKeys(n *int) uint32 { + if n == nil { + return 0 } - return okOrError(resp.StatusCode(), resp.Body) + return uint32(*n) } // AddWorkspaceMembersToProject grants a set of existing workspace members access diff --git a/pkg/public/members_test.go b/pkg/public/members_test.go new file mode 100644 index 000000000..a2717b4f2 --- /dev/null +++ b/pkg/public/members_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The bodies are what the server's REST transcoder writes: the removal's +// numKeys, or a google.rpc.Status carrying a MemberRemovalNotConfirmed. +const ( + notConfirmedBody = `{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[` + + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}` + lastKeyBody = `{"code":9, "message":"removing this member would delete the API keys they own (3 in total), including the last API key of \"prod\", 1 private project(s); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key first", "details":[` + + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":["prod"], "numPrivateLastKeyProjects":1}]}` +) + +// TestRemoveMember checks that both removals send the confirmed count, leaving +// out a count of 0, and return the count the API deleted. +func TestRemoveMember(t *testing.T) { + tests := []struct { + name string + remove func(c *Client, confirmed uint32) (uint32, error) + wantPath string + }{ + { + name: "project", + remove: func(c *Client, confirmed uint32) (uint32, error) { + return c.RemoveProjectMember(context.Background(), "p_1", "u_1", confirmed) + }, + wantPath: "/v0/projects/p_1/members/u_1", + }, + { + name: "workspace", + remove: func(c *Client, confirmed uint32) (uint32, error) { + return c.DeleteWorkspaceMember(context.Background(), "ws_1", "u_1", confirmed) + }, + wantPath: "/v0/workspaces/ws_1/members/u_1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("confirmedNumKeys") { + case "": + _, _ = w.Write([]byte(`{}`)) + default: + _, _ = w.Write([]byte(`{"numKeys":3}`)) + } + })) + t.Cleanup(srv.Close) + c, err := New(srv.URL, "tok") + require.NoError(t, err) + + n, err := tt.remove(c, 0) + require.NoError(t, err) + assert.Equal(t, uint32(0), n, "a member who owns no keys") + + n, err = tt.remove(c, 3) + require.NoError(t, err) + assert.Equal(t, uint32(3), n) + + assert.Equal(t, []string{ + "DELETE " + tt.wantPath, + "DELETE " + tt.wantPath + "?confirmedNumKeys=3", + }, calls) + }) + } +} + +func TestMemberRemovalNotConfirmed(t *testing.T) { + tests := []struct { + name string + body string + wantRemoval *MemberRemoval + }{ + { + name: "keys to confirm", + body: notConfirmedBody, + wantRemoval: &MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{}}, + }, + { + name: "last key", + body: lastKeyBody, + wantRemoval: &MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod"}, NumPrivateLastKeyProjects: 1}, + }, + { + name: "another failed precondition", + body: `{"code":9,"message":"something else","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"X"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := jsonServer(t, http.StatusBadRequest, tt.body, nil, nil) + c, err := New(srv.URL, "tok") + require.NoError(t, err) + + _, err = c.RemoveProjectMember(context.Background(), "p_1", "u_1", 0) + require.Error(t, err) + removal, ok := MemberRemovalNotConfirmed(err) + assert.Equal(t, tt.wantRemoval != nil, ok) + assert.Equal(t, tt.wantRemoval, removal) + }) + } + + t.Run("not an API error", func(t *testing.T) { + _, ok := MemberRemovalNotConfirmed(errors.New("connection refused")) + assert.False(t, ok) + }) +} + +func TestMemberRemovalLeavesProjectWithoutKey(t *testing.T) { + assert.False(t, (&MemberRemoval{NumKeys: 3}).LeavesProjectWithoutKey()) + assert.True(t, (&MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod"}}).LeavesProjectWithoutKey()) + assert.True(t, (&MemberRemoval{NumKeys: 3, NumPrivateLastKeyProjects: 1}).LeavesProjectWithoutKey()) +} diff --git a/pkg/public/workspaces.go b/pkg/public/workspaces.go index 12f929c5d..06f9296eb 100644 --- a/pkg/public/workspaces.go +++ b/pkg/public/workspaces.go @@ -194,13 +194,20 @@ func (c *Client) UpdateWorkspaceMember(ctx context.Context, workspaceID, userID return requirePayload(resp.JSON200.Member, "member") } -// DeleteWorkspaceMember removes a member from a workspace. -func (c *Client) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error { - resp, err := c.gen.WorkspaceServiceDeleteMemberWithResponse(ctx, workspaceID, userID, nil) +// DeleteWorkspaceMember removes a member from a workspace and its projects, +// deletes the API keys they own there, and returns how many it deleted. +// confirmedNumKeys must be that count, as for RemoveProjectMember. +func (c *Client) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string, confirmedNumKeys uint32) (uint32, error) { + resp, err := c.gen.WorkspaceServiceDeleteMemberWithResponse(ctx, workspaceID, userID, &oapi.WorkspaceServiceDeleteMemberParams{ + ConfirmedNumKeys: confirmedNumKeysParam(confirmedNumKeys), + }) if err != nil { - return err + return 0, err } - return okOrError(resp.StatusCode(), resp.Body) + if resp.JSON200 == nil { + return 0, responseError(resp.StatusCode(), resp.Body) + } + return numKeys(resp.JSON200.NumKeys), nil } // ListWorkspaceInvites returns the pending invites for a workspace. From f1ebb8e684cc4d870ea17a632afcaf8d6fc48438 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Mon, 26 Oct 2026 14:07:49 -0700 Subject: [PATCH 3/6] feat(lk): confirm a member removal that deletes API keys lk project member remove and lk workspace member delete first ask the Public API to remove the member with no keys confirmed. When the member owns API keys, the API refuses and says how many; lk asks the user to confirm deleting them and retries with that count, then says how many keys the removal deleted (numKeys in --json). --yes confirms without asking. A run that can't prompt fails and says to re-run with --yes, rather than deleting keys unasked as confirmDestroy would. A removal that would leave a project without an API key is refused even when confirmed, so lk names the projects (and counts the private ones) and doesn't offer to confirm. If the count changes before the retry, lk asks once more with the new count, and fails if it changes again. --- cmd/lk/member_removal.go | 120 +++++++++++++++++ cmd/lk/member_removal_test.go | 245 ++++++++++++++++++++++++++++++++++ cmd/lk/project_members.go | 9 +- cmd/lk/workspace.go | 9 +- 4 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 cmd/lk/member_removal.go create mode 100644 cmd/lk/member_removal_test.go diff --git a/cmd/lk/member_removal.go b/cmd/lk/member_removal.go new file mode 100644 index 000000000..c719f7448 --- /dev/null +++ b/cmd/lk/member_removal.go @@ -0,0 +1,120 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/public" + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +// maxKeyConfirmations is how many times removeMember asks the user to confirm +// a key count: once, and once more if the count changes before the removal +// goes ahead. +const maxKeyConfirmations = 2 + +// removeMember removes userID with remove, which also deletes the API keys +// they own, and returns how many it deleted. remove is called with +// confirmedNumKeys 0 first. If the member owns keys, the Public API refuses +// with their number; once the user confirms it (or passed --yes), remove is +// called again with that number. A removal that would leave a project without +// an API key is refused without asking. +func removeMember(ctx context.Context, cmd *cli.Command, userID string, remove func(confirmedNumKeys uint32) (uint32, error)) (uint32, error) { + var confirmed uint32 + for asked := 0; ; asked++ { + numKeys, err := remove(confirmed) + removal, ok := public.MemberRemovalNotConfirmed(err) + if !ok { + return numKeys, err + } + if removal.LeavesProjectWithoutKey() { + return 0, lastKeyError(userID, removal) + } + if asked == maxKeyConfirmations { + return 0, fmt.Errorf("%s's API keys changed again (they now own %d); nothing was removed, run the command again", userID, removal.NumKeys) + } + if err := confirmKeyDeletion(ctx, cmd, userID, removal.NumKeys, asked > 0); err != nil { + return 0, err + } + confirmed = removal.NumKeys + } +} + +// confirmKeyDeletion asks the user to agree to deleting the numKeys API keys +// userID owns. --yes agrees; without it a non-interactive run fails, since the +// deletion can't be undone. +func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID string, numKeys uint32, changed bool) error { + if cmd.Bool("yes") { + return nil + } + if !isInteractive() { + return fmt.Errorf("removing %s deletes the %d API key(s) they own, and anything using them stops working; re-run with --yes to remove them", userID, numKeys) + } + title := fmt.Sprintf("Remove %s and delete the %d API key(s) they own?", userID, numKeys) + if changed { + title = fmt.Sprintf("%s's API keys changed. Remove %s and delete the %d API key(s) they own now?", userID, userID, numKeys) + } + confirmed := false + if err := huh.NewForm(huh.NewGroup(util.Confirm(). + Title(title). + Description("Anything using those keys stops working."). + Value(&confirmed). + WithTheme(util.FormTheme()))). + RunWithContext(ctx); err != nil { + return err + } + if !confirmed { + return errors.New("aborted") + } + return nil +} + +// lastKeyError says a removal is refused because it would leave projects +// without an API key, and what to do first. +func lastKeyError(userID string, removal *public.MemberRemoval) error { + projects := make([]string, 0, len(removal.LastKeyProjectNames)+1) + for _, name := range removal.LastKeyProjectNames { + projects = append(projects, fmt.Sprintf("%q", name)) + } + if removal.NumPrivateLastKeyProjects > 0 { + projects = append(projects, fmt.Sprintf("%d private project(s)", removal.NumPrivateLastKeyProjects)) + } + list := projects[len(projects)-1] + if len(projects) > 1 { + list = strings.Join(projects[:len(projects)-1], ", ") + " and " + list + } + return fmt.Errorf("can't remove %s: it would delete the last API key of %s; a project without an API key stops handling inbound SIP calls and webhooks, "+ + "so have a member who is staying create a replacement API key there first", userID, list) +} + +// renderMemberRemoved reports a removal and the API keys it deleted. +func renderMemberRemoved(cmd *cli.Command, userID string, numKeys uint32) error { + if cmd.Bool("json") { + return util.PrintJSONTo(out.ResultWriter(), map[string]any{"userId": userID, "removed": true, "numKeys": numKeys}) + } + if numKeys == 0 { + out.Statusf("Removed member %s", util.Accented(userID)) + return nil + } + out.Statusf("Removed member %s and deleted the %d API key(s) they owned", util.Accented(userID), numKeys) + return nil +} diff --git a/cmd/lk/member_removal_test.go b/cmd/lk/member_removal_test.go new file mode 100644 index 000000000..6cd5aa385 --- /dev/null +++ b/cmd/lk/member_removal_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "cmp" + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/public" +) + +// notConfirmed is the google.rpc.Status the Public API answers a removal with +// while the member owns numKeys keys the caller didn't confirm. +func notConfirmed(numKeys int) string { + return fmt.Sprintf(`{"code":9,"message":"removing this member deletes the API keys they own (%[1]d in total), and services using them stop working; repeat the request with confirmed_num_keys=%[1]d to remove them","details":[`+ + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed","numKeys":%[1]d,"lastKeyProjectNames":[],"numPrivateLastKeyProjects":0}]}`, numKeys) +} + +const lastKey = `{"code":9,"message":"removing this member would delete the API keys they own (3 in total), including the last API key of \"prod\", 1 private project(s); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key first","details":[` + + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed","numKeys":3,"lastKeyProjectNames":["prod"],"numPrivateLastKeyProjects":1}]}` + +type cannedRemoval struct { + status int + body string +} + +// TestRemoveMember runs removeMember against a stand-in Public API that answers +// each confirmedNumKeys with a canned removal, as lk runs non-interactively. +func TestRemoveMember(t *testing.T) { + tests := []struct { + name string + yes bool + // answers are by the confirmedNumKeys the request sent. + answers map[string]cannedRemoval + wantSent []string + wantNumKeys uint32 + wantErr string + }{ + { + name: "no keys", + answers: map[string]cannedRemoval{"0": {http.StatusOK, `{}`}}, + wantSent: []string{"0"}, + }, + { + name: "keys confirmed with --yes", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusOK, `{"numKeys":3}`}, + }, + wantSent: []string{"0", "3"}, + wantNumKeys: 3, + }, + { + name: "keys without --yes", + answers: map[string]cannedRemoval{"0": {http.StatusBadRequest, notConfirmed(3)}}, + wantSent: []string{"0"}, + wantErr: "removing u_2 deletes the 3 API key(s) they own, and anything using them stops working; re-run with --yes to remove them", + }, + { + name: "count changes once", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, notConfirmed(4)}, + "4": {http.StatusOK, `{"numKeys":4}`}, + }, + wantSent: []string{"0", "3", "4"}, + wantNumKeys: 4, + }, + { + name: "count changes twice", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, notConfirmed(4)}, + "4": {http.StatusBadRequest, notConfirmed(5)}, + }, + wantSent: []string{"0", "3", "4"}, + wantErr: "u_2's API keys changed again (they now own 5); nothing was removed, run the command again", + }, + { + name: "last key, even with --yes", + yes: true, + answers: map[string]cannedRemoval{"0": {http.StatusBadRequest, lastKey}}, + wantSent: []string{"0"}, + wantErr: `can't remove u_2: it would delete the last API key of "prod" and 1 private project(s)`, + }, + { + name: "last key after confirming", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, lastKey}, + }, + wantSent: []string{"0", "3"}, + wantErr: `can't remove u_2: it would delete the last API key of "prod" and 1 private project(s)`, + }, + { + name: "another refusal", + yes: true, + answers: map[string]cannedRemoval{"0": {http.StatusForbidden, `{"code":7,"message":"permission denied"}`}}, + wantSent: []string{"0"}, + wantErr: "permission denied", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // A run with no terminal, the way CI runs lk. + t.Setenv("CI", "true") + var sent []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // lk leaves out a count of 0, which the API reads as 0. + confirmed := cmp.Or(r.URL.Query().Get("confirmedNumKeys"), "0") + sent = append(sent, confirmed) + answer, ok := tt.answers[confirmed] + if !assert.True(t, ok, "no answer for confirmedNumKeys=%s", confirmed) { + w.WriteHeader(http.StatusNotImplemented) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(answer.status) + _, _ = w.Write([]byte(answer.body)) + })) + t.Cleanup(srv.Close) + client, err := public.New(srv.URL, "tok") + require.NoError(t, err) + + var numKeys uint32 + var removeErr error + app := &cli.Command{ + Name: "lk", + Flags: []cli.Flag{&cli.BoolFlag{Name: "yes"}}, + Action: func(ctx context.Context, cmd *cli.Command) error { + numKeys, removeErr = removeMember(ctx, cmd, "u_2", func(confirmedNumKeys uint32) (uint32, error) { + return client.RemoveProjectMember(ctx, "p_1", "u_2", confirmedNumKeys) + }) + return nil + }, + } + args := []string{"lk"} + if tt.yes { + args = append(args, "--yes") + } + require.NoError(t, app.Run(context.Background(), args)) + + assert.Equal(t, tt.wantSent, sent) + if tt.wantErr != "" { + assert.ErrorContains(t, removeErr, tt.wantErr) + return + } + require.NoError(t, removeErr) + assert.Equal(t, tt.wantNumKeys, numKeys) + }) + } +} + +func TestLastKeyError(t *testing.T) { + const why = "; a project without an API key stops handling inbound SIP calls and webhooks, " + + "so have a member who is staying create a replacement API key there first" + tests := []struct { + name string + removal public.MemberRemoval + want string + }{ + { + name: "named projects", + removal: public.MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod", "staging"}}, + want: `can't remove u_2: it would delete the last API key of "prod" and "staging"` + why, + }, + { + name: "three named projects", + removal: public.MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"dev", "prod", "staging"}}, + want: `can't remove u_2: it would delete the last API key of "dev", "prod" and "staging"` + why, + }, + { + name: "private projects only", + removal: public.MemberRemoval{NumKeys: 3, NumPrivateLastKeyProjects: 2}, + want: `can't remove u_2: it would delete the last API key of 2 private project(s)` + why, + }, + { + name: "both", + removal: public.MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod"}, NumPrivateLastKeyProjects: 1}, + want: `can't remove u_2: it would delete the last API key of "prod" and 1 private project(s)` + why, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.EqualError(t, lastKeyError("u_2", &tt.removal), tt.want) + }) + } +} + +func TestRenderMemberRemoved(t *testing.T) { + tests := []struct { + name string + json bool + numKeys uint32 + wantStdout string + wantStderr string + }{ + {name: "no keys", wantStderr: "Removed member u_2\n"}, + {name: "keys", numKeys: 3, wantStderr: "Removed member u_2 and deleted the 3 API key(s) they owned\n"}, + {name: "json", json: true, numKeys: 3, wantStdout: "{\n \"numKeys\": 3,\n \"removed\": true,\n \"userId\": \"u_2\"\n}\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr := captureOut(t) + app := &cli.Command{ + Name: "lk", + Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}}, + Action: func(_ context.Context, cmd *cli.Command) error { + return renderMemberRemoved(cmd, "u_2", tt.numKeys) + }, + } + args := []string{"lk"} + if tt.json { + args = append(args, "--json") + } + require.NoError(t, app.Run(context.Background(), args)) + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/cmd/lk/project_members.go b/cmd/lk/project_members.go index 309e7a732..01b0cef31 100644 --- a/cmd/lk/project_members.go +++ b/cmd/lk/project_members.go @@ -61,7 +61,7 @@ func init() { }, { Name: "remove", - Usage: "Remove a member from a project", + Usage: "Remove a member from a project and delete the API keys they own", UsageText: "lk project member remove USER_ID --project PROJECT --user-auth", ArgsUsage: "USER_ID", Action: removeProjectMember, @@ -223,10 +223,13 @@ func removeProjectMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - if _, err := client.RemoveProjectMember(ctx, projectID, userID, 0); err != nil { + numKeys, err := removeMember(ctx, cmd, userID, func(confirmedNumKeys uint32) (uint32, error) { + return client.RemoveProjectMember(ctx, projectID, userID, confirmedNumKeys) + }) + if err != nil { return cloudAPIError(err) } - return util.RenderAction(out, cmd.Bool("json"), "userId", userID, "removed", fmt.Sprintf("Removed member %s", util.Accented(userID))) + return renderMemberRemoved(cmd, userID, numKeys) } func addProjectMembers(ctx context.Context, cmd *cli.Command) error { diff --git a/cmd/lk/workspace.go b/cmd/lk/workspace.go index 8f85081fa..99b09af17 100644 --- a/cmd/lk/workspace.go +++ b/cmd/lk/workspace.go @@ -175,7 +175,7 @@ func workspaceMemberCommand() *cli.Command { }, { Name: "delete", - Usage: "Remove a member from a workspace", + Usage: "Remove a member from a workspace and delete the API keys they own", UsageText: "lk workspace member delete USER_ID --workspace WORKSPACE --user-auth", ArgsUsage: "USER_ID", Action: deleteWorkspaceMember, @@ -491,10 +491,13 @@ func deleteWorkspaceMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - if _, err := client.DeleteWorkspaceMember(ctx, wsID, userID, 0); err != nil { + numKeys, err := removeMember(ctx, cmd, userID, func(confirmedNumKeys uint32) (uint32, error) { + return client.DeleteWorkspaceMember(ctx, wsID, userID, confirmedNumKeys) + }) + if err != nil { return cloudAPIError(err) } - return util.RenderAction(out, cmd.Bool("json"), "userId", userID, "removed", fmt.Sprintf("Removed member %s", util.Accented(userID))) + return renderMemberRemoved(cmd, userID, numKeys) } func listWorkspaceInvites(ctx context.Context, cmd *cli.Command) error { From 3d8ddd7e1f5614d115b66ca0f06b45e70338276e Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Mon, 26 Oct 2026 14:09:43 -0700 Subject: [PATCH 4/6] test(lk): scripts for a member removal that deletes API keys Four scripts cover a member who owns no keys, keys confirmed with --yes (and refused without it when lk can't prompt), the count changing between calls, and a removal that would take a project's last key. The fake cloud answered a URL whatever its query, so it couldn't tell lk's first call from its retry. A respond URL with a query now answers only that query and wins over one without, so each retry gets the answer for the count it confirms, and a wrong count gets the first call's refusal again. The two member removal routes join publicAPIRoutes, so their canned bodies are checked against the generated types. --- cmd/lk/script_test.go | 23 ++++++--- .../script/member-removal-confirmed.txtar | 50 +++++++++++++++++++ .../script/member-removal-count-changes.txtar | 43 ++++++++++++++++ .../script/member-removal-last-key.txtar | 27 ++++++++++ .../script/member-removal-no-keys.txtar | 27 ++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 cmd/lk/testdata/script/member-removal-confirmed.txtar create mode 100644 cmd/lk/testdata/script/member-removal-count-changes.txtar create mode 100644 cmd/lk/testdata/script/member-removal-last-key.txtar create mode 100644 cmd/lk/testdata/script/member-removal-no-keys.txtar diff --git a/cmd/lk/script_test.go b/cmd/lk/script_test.go index bf24c0edb..ede3c029c 100644 --- a/cmd/lk/script_test.go +++ b/cmd/lk/script_test.go @@ -34,9 +34,11 @@ package main // Two script commands drive it: // // respond METHOD URL STATUS [BODY] -// Answer METHOD URL (any query) with STATUS and BODY. A request with -// no answer gets 501. A Public API body (a /v0 path) must be what the -// spec says that route sends: see checkPublicAPIBody. +// Answer METHOD URL with STATUS and BODY. A URL without a query +// answers any query; one with a query answers only that query, and +// wins over one without. A request with no answer gets 501. A Public +// API body (a /v0 path) must be what the spec says that route sends: +// see checkPublicAPIBody. // requests // Print every request the fake received, in order, with the // credential it carried (a session token as bearer=, or as apikey= the @@ -123,7 +125,7 @@ type fakeCloud struct { *httptest.Server mu sync.Mutex - responses map[string]cannedResponse // by "METHOD URL", without the query + responses map[string]cannedResponse // by "METHOD URL", with the query only if respond had one log []string // mint makes the fake answer a Public API token request it has no canned @@ -192,7 +194,10 @@ func (f *fakeCloud) serve(w http.ResponseWriter, r *http.Request) { } f.log = append(f.log, line) - res, ok := f.responses[target] + res, ok := f.responses[target+"?"+r.URL.Query().Encode()] + if !ok { + res, ok = f.responses[target] + } if m := tokenRequestPath.FindStringSubmatch(r.URL.Path); !ok && f.mint && r.Method == http.MethodPost && m != nil { res, ok = f.mintToken(r, m[1]), true } @@ -278,7 +283,11 @@ func (f *fakeCloud) respond(target string, status int, body string) error { } f.mu.Lock() defer f.mu.Unlock() - f.responses[method+" "+u.Scheme+"://"+u.Host+u.Path] = cannedResponse{status, body} + key := method + " " + u.Scheme + "://" + u.Host + u.Path + if u.RawQuery != "" { + key += "?" + u.Query().Encode() + } + f.responses[key] = cannedResponse{status, body} return nil } @@ -295,6 +304,8 @@ var publicAPIRoutes = []struct { {http.MethodPost, regexp.MustCompile(`^/v0/projects/[^/]+/keys$`), func() any { return new(oapi.LivekitPublicapiProjectsV1GenerateKeyResponse) }}, {http.MethodDelete, regexp.MustCompile(`^/v0/projects/[^/]+/keys/[^/]+$`), func() any { return new(oapi.LivekitPublicapiProjectsV1DeleteKeyResponse) }}, {http.MethodGet, regexp.MustCompile(`^/v0/projects/[^/]+/members$`), func() any { return new(oapi.LivekitPublicapiProjectsV1ListMembersResponse) }}, + {http.MethodDelete, regexp.MustCompile(`^/v0/projects/[^/]+/members/[^/]+$`), func() any { return new(oapi.LivekitPublicapiProjectsV1RemoveMemberResponse) }}, + {http.MethodDelete, regexp.MustCompile(`^/v0/workspaces/[^/]+/members/[^/]+$`), func() any { return new(oapi.LivekitPublicapiWorkspacesV1DeleteMemberResponse) }}, } // middlewareError is the body the Public API's auth middleware and panic diff --git a/cmd/lk/testdata/script/member-removal-confirmed.txtar b/cmd/lk/testdata/script/member-removal-confirmed.txtar new file mode 100644 index 000000000..54a4bca63 --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-confirmed.txtar @@ -0,0 +1,50 @@ +# Removing a member who owns API keys deletes them only once confirmed. The +# Public API refuses the first call, which confirms 0, and says how many keys +# the member owns. A run that can't prompt then fails and says to pass --yes; +# with --yes, lk calls again with that count and says how many keys went. +# lk leaves the first call's count of 0 out, so its answer has no query; the +# retry's answer is only for the confirmedNumKeys it names, and wins. +respond DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 400 '{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2?confirmedNumKeys=3 200 '{"numKeys":3}' + +! exec lk --user-auth workspace member delete u_2 --workspace acme +! stdout . +stderr '^removing u_2 deletes the 3 API key\(s\) they own, and anything using them stops working; re-run with --yes to remove them$' + +exec lk --user-auth --yes workspace member delete u_2 --workspace acme +! stdout . +stderr '^Removed member u_2 and deleted the 3 API key\(s\) they owned$' + +exec lk --user-auth --yes workspace member delete u_2 --workspace acme --json +cmp stdout want-json + +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-json -- +{ + "numKeys": 3, + "removed": true, + "userId": "u_2" +} +-- want-requests -- +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin diff --git a/cmd/lk/testdata/script/member-removal-count-changes.txtar b/cmd/lk/testdata/script/member-removal-count-changes.txtar new file mode 100644 index 000000000..46ef807a0 --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-count-changes.txtar @@ -0,0 +1,43 @@ +# If the member's API keys change between lk's calls, the Public API refuses +# the confirmed count and reports the new one. lk confirms once more (here +# with --yes) and goes ahead; if the count changes again, it fails and +# nothing is removed. lk leaves the first call's count of 0 out, so its +# answer has no query; each retry's answer is only for the confirmedNumKeys +# it names, and wins. +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 400 '{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2?confirmedNumKeys=3 400 '{"code":9, "message":"removing this member deletes the API keys they own (4 in total), and services using them stop working; repeat the request with confirmed_num_keys=4 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":4, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2?confirmedNumKeys=4 200 '{"numKeys":4}' +exec lk --user-auth --yes project member remove u_2 --project one +stderr '^Removed member u_2 and deleted the 4 API key\(s\) they owned$' + +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_3 400 '{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_3?confirmedNumKeys=3 400 '{"code":9, "message":"removing this member deletes the API keys they own (4 in total), and services using them stop working; repeat the request with confirmed_num_keys=4 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":4, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_3?confirmedNumKeys=4 400 '{"code":9, "message":"removing this member deletes the API keys they own (5 in total), and services using them stop working; repeat the request with confirmed_num_keys=5 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":5, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +! exec lk --user-auth --yes project member remove u_3 --project one +stderr '^u_3''s API keys changed again \(they now own 5\); nothing was removed, run the command again$' + +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-requests -- +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_3 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_3 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_3 bearer=st_admin project=p_1 diff --git a/cmd/lk/testdata/script/member-removal-last-key.txtar b/cmd/lk/testdata/script/member-removal-last-key.txtar new file mode 100644 index 000000000..0320a8751 --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-last-key.txtar @@ -0,0 +1,27 @@ +# A removal that would leave a project without an API key is refused even +# when the count is confirmed, so lk names the projects, counts the private +# ones, and makes no second call, even with --yes. +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 400 '{"code":9, "message":"removing this member would delete the API keys they own (3 in total), including the last API key of \"one\", 1 private project(s); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key first", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":["one"], "numPrivateLastKeyProjects":1}]}' +! exec lk --user-auth --yes project member remove u_2 --project one +! stdout . +stderr '^can''t remove u_2: it would delete the last API key of "one" and 1 private project\(s\); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key there first$' +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-requests -- +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 diff --git a/cmd/lk/testdata/script/member-removal-no-keys.txtar b/cmd/lk/testdata/script/member-removal-no-keys.txtar new file mode 100644 index 000000000..e73b22bea --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-no-keys.txtar @@ -0,0 +1,27 @@ +# Removing a member who owns no API keys goes ahead without asking: lk +# confirms 0 keys, which is all the member owns. It leaves a count of 0 out +# of the request, which the API reads as 0. +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 200 '{"numKeys":0}' +exec lk --user-auth project member remove u_2 --project one +! stdout . +stderr '^Removed member u_2$' +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-requests -- +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 From cbe5403ddd6b32de70f50d238886f7d6c49a8e7d Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Mon, 26 Oct 2026 14:52:48 -0700 Subject: [PATCH 5/6] feat(lk): say a project member removal reaches the whole workspace A project that isn't private shares its members with its workspace, so the Public API removes the member from the workspace and every project in it, and the key count covers all of them. lk project member remove now says so in its help and in the confirmation prompt. --- cmd/lk/member_removal.go | 20 +++++++++++++++----- cmd/lk/member_removal_test.go | 2 +- cmd/lk/project_members.go | 4 +++- cmd/lk/workspace.go | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cmd/lk/member_removal.go b/cmd/lk/member_removal.go index c719f7448..e51f67aac 100644 --- a/cmd/lk/member_removal.go +++ b/cmd/lk/member_removal.go @@ -32,13 +32,19 @@ import ( // goes ahead. const maxKeyConfirmations = 2 +// projectRemovalScope tells the user how far a project member removal +// reaches: a project that isn't private shares its members with its +// workspace, so the Public API removes the member from all of it. +const projectRemovalScope = "Unless the project is private, this removes them from its whole workspace and every project in it." + // removeMember removes userID with remove, which also deletes the API keys // they own, and returns how many it deleted. remove is called with // confirmedNumKeys 0 first. If the member owns keys, the Public API refuses // with their number; once the user confirms it (or passed --yes), remove is // called again with that number. A removal that would leave a project without -// an API key is refused without asking. -func removeMember(ctx context.Context, cmd *cli.Command, userID string, remove func(confirmedNumKeys uint32) (uint32, error)) (uint32, error) { +// an API key is refused without asking. scope, if set, is shown with the +// confirmation. +func removeMember(ctx context.Context, cmd *cli.Command, userID, scope string, remove func(confirmedNumKeys uint32) (uint32, error)) (uint32, error) { var confirmed uint32 for asked := 0; ; asked++ { numKeys, err := remove(confirmed) @@ -52,7 +58,7 @@ func removeMember(ctx context.Context, cmd *cli.Command, userID string, remove f if asked == maxKeyConfirmations { return 0, fmt.Errorf("%s's API keys changed again (they now own %d); nothing was removed, run the command again", userID, removal.NumKeys) } - if err := confirmKeyDeletion(ctx, cmd, userID, removal.NumKeys, asked > 0); err != nil { + if err := confirmKeyDeletion(ctx, cmd, userID, scope, removal.NumKeys, asked > 0); err != nil { return 0, err } confirmed = removal.NumKeys @@ -62,7 +68,7 @@ func removeMember(ctx context.Context, cmd *cli.Command, userID string, remove f // confirmKeyDeletion asks the user to agree to deleting the numKeys API keys // userID owns. --yes agrees; without it a non-interactive run fails, since the // deletion can't be undone. -func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID string, numKeys uint32, changed bool) error { +func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID, scope string, numKeys uint32, changed bool) error { if cmd.Bool("yes") { return nil } @@ -73,10 +79,14 @@ func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID string, nu if changed { title = fmt.Sprintf("%s's API keys changed. Remove %s and delete the %d API key(s) they own now?", userID, userID, numKeys) } + description := "Anything using those keys stops working." + if scope != "" { + description = scope + " " + description + } confirmed := false if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title(title). - Description("Anything using those keys stops working."). + Description(description). Value(&confirmed). WithTheme(util.FormTheme()))). RunWithContext(ctx); err != nil { diff --git a/cmd/lk/member_removal_test.go b/cmd/lk/member_removal_test.go index 6cd5aa385..d41cad6a2 100644 --- a/cmd/lk/member_removal_test.go +++ b/cmd/lk/member_removal_test.go @@ -152,7 +152,7 @@ func TestRemoveMember(t *testing.T) { Name: "lk", Flags: []cli.Flag{&cli.BoolFlag{Name: "yes"}}, Action: func(ctx context.Context, cmd *cli.Command) error { - numKeys, removeErr = removeMember(ctx, cmd, "u_2", func(confirmedNumKeys uint32) (uint32, error) { + numKeys, removeErr = removeMember(ctx, cmd, "u_2", projectRemovalScope, func(confirmedNumKeys uint32) (uint32, error) { return client.RemoveProjectMember(ctx, "p_1", "u_2", confirmedNumKeys) }) return nil diff --git a/cmd/lk/project_members.go b/cmd/lk/project_members.go index 01b0cef31..8f5624319 100644 --- a/cmd/lk/project_members.go +++ b/cmd/lk/project_members.go @@ -63,6 +63,8 @@ func init() { Name: "remove", Usage: "Remove a member from a project and delete the API keys they own", UsageText: "lk project member remove USER_ID --project PROJECT --user-auth", + Description: "Unless the project is private, it shares its members with its workspace, so this removes the member\n" + + "from the workspace and every project in it, and deletes the API keys they own in any of them.", ArgsUsage: "USER_ID", Action: removeProjectMember, Flags: []cli.Flag{jsonFlag}, @@ -223,7 +225,7 @@ func removeProjectMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - numKeys, err := removeMember(ctx, cmd, userID, func(confirmedNumKeys uint32) (uint32, error) { + numKeys, err := removeMember(ctx, cmd, userID, projectRemovalScope, func(confirmedNumKeys uint32) (uint32, error) { return client.RemoveProjectMember(ctx, projectID, userID, confirmedNumKeys) }) if err != nil { diff --git a/cmd/lk/workspace.go b/cmd/lk/workspace.go index 99b09af17..f36f563d5 100644 --- a/cmd/lk/workspace.go +++ b/cmd/lk/workspace.go @@ -491,7 +491,7 @@ func deleteWorkspaceMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - numKeys, err := removeMember(ctx, cmd, userID, func(confirmedNumKeys uint32) (uint32, error) { + numKeys, err := removeMember(ctx, cmd, userID, "", func(confirmedNumKeys uint32) (uint32, error) { return client.DeleteWorkspaceMember(ctx, wsID, userID, confirmedNumKeys) }) if err != nil { From 21991efef3ffd88c9d6ad21e4123cbf5905f1d0d Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Mon, 26 Oct 2026 17:05:47 -0700 Subject: [PATCH 6/6] fix(lk): tidy the member removal confirmation If the member's keys are revoked between lk's calls, the retry is refused with a count of 0; lk now retries with 0 instead of asking to delete 0 keys. Ctrl-C at the prompt prints "aborted", as the other destructive commands do. A run that can't prompt now says that a project removal reaches the whole workspace, as the prompt does, and says to re-run with --yes "to go ahead". The no-keys script answers an explicit confirmedNumKeys=0 with the 500 a server from before the count sends, so it fails if lk stops leaving the 0 out. --- cmd/lk/member_removal.go | 14 ++++++++++- cmd/lk/member_removal_test.go | 23 ++++++++++++++++--- .../script/member-removal-confirmed.txtar | 2 +- .../script/member-removal-no-keys.txtar | 4 +++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/cmd/lk/member_removal.go b/cmd/lk/member_removal.go index e51f67aac..062d2c1ea 100644 --- a/cmd/lk/member_removal.go +++ b/cmd/lk/member_removal.go @@ -58,6 +58,11 @@ func removeMember(ctx context.Context, cmd *cli.Command, userID, scope string, r if asked == maxKeyConfirmations { return 0, fmt.Errorf("%s's API keys changed again (they now own %d); nothing was removed, run the command again", userID, removal.NumKeys) } + if removal.NumKeys == 0 { + // Their keys went before the retry, so there's nothing to confirm. + confirmed = 0 + continue + } if err := confirmKeyDeletion(ctx, cmd, userID, scope, removal.NumKeys, asked > 0); err != nil { return 0, err } @@ -73,7 +78,11 @@ func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID, scope str return nil } if !isInteractive() { - return fmt.Errorf("removing %s deletes the %d API key(s) they own, and anything using them stops working; re-run with --yes to remove them", userID, numKeys) + msg := fmt.Sprintf("removing %s deletes the %d API key(s) they own, and anything using them stops working", userID, numKeys) + if scope != "" { + msg += "; " + strings.ToLower(scope[:1]) + strings.TrimSuffix(scope[1:], ".") + } + return errors.New(msg + "; re-run with --yes to go ahead") } title := fmt.Sprintf("Remove %s and delete the %d API key(s) they own?", userID, numKeys) if changed { @@ -90,6 +99,9 @@ func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID, scope str Value(&confirmed). WithTheme(util.FormTheme()))). RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return errors.New("aborted") + } return err } if !confirmed { diff --git a/cmd/lk/member_removal_test.go b/cmd/lk/member_removal_test.go index d41cad6a2..a682e7097 100644 --- a/cmd/lk/member_removal_test.go +++ b/cmd/lk/member_removal_test.go @@ -20,6 +20,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -51,7 +52,9 @@ func TestRemoveMember(t *testing.T) { name string yes bool // answers are by the confirmedNumKeys the request sent. - answers map[string]cannedRemoval + answers map[string]cannedRemoval + // again answers a confirmedNumKeys the second time it is sent. + again map[string]cannedRemoval wantSent []string wantNumKeys uint32 wantErr string @@ -75,7 +78,18 @@ func TestRemoveMember(t *testing.T) { name: "keys without --yes", answers: map[string]cannedRemoval{"0": {http.StatusBadRequest, notConfirmed(3)}}, wantSent: []string{"0"}, - wantErr: "removing u_2 deletes the 3 API key(s) they own, and anything using them stops working; re-run with --yes to remove them", + wantErr: "removing u_2 deletes the 3 API key(s) they own, and anything using them stops working; " + + "unless the project is private, this removes them from its whole workspace and every project in it; re-run with --yes to go ahead", + }, + { + name: "keys revoked before the retry", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, notConfirmed(0)}, + }, + again: map[string]cannedRemoval{"0": {http.StatusOK, `{}`}}, + wantSent: []string{"0", "3", "0"}, }, { name: "count changes once", @@ -132,8 +146,11 @@ func TestRemoveMember(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // lk leaves out a count of 0, which the API reads as 0. confirmed := cmp.Or(r.URL.Query().Get("confirmedNumKeys"), "0") - sent = append(sent, confirmed) answer, ok := tt.answers[confirmed] + if again, seen := tt.again[confirmed]; seen && slices.Contains(sent, confirmed) { + answer, ok = again, true + } + sent = append(sent, confirmed) if !assert.True(t, ok, "no answer for confirmedNumKeys=%s", confirmed) { w.WriteHeader(http.StatusNotImplemented) return diff --git a/cmd/lk/testdata/script/member-removal-confirmed.txtar b/cmd/lk/testdata/script/member-removal-confirmed.txtar index 54a4bca63..2758ab9b3 100644 --- a/cmd/lk/testdata/script/member-removal-confirmed.txtar +++ b/cmd/lk/testdata/script/member-removal-confirmed.txtar @@ -9,7 +9,7 @@ respond DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2?confirmedNum ! exec lk --user-auth workspace member delete u_2 --workspace acme ! stdout . -stderr '^removing u_2 deletes the 3 API key\(s\) they own, and anything using them stops working; re-run with --yes to remove them$' +stderr '^removing u_2 deletes the 3 API key\(s\) they own, and anything using them stops working; re-run with --yes to go ahead$' exec lk --user-auth --yes workspace member delete u_2 --workspace acme ! stdout . diff --git a/cmd/lk/testdata/script/member-removal-no-keys.txtar b/cmd/lk/testdata/script/member-removal-no-keys.txtar index e73b22bea..89b05aff7 100644 --- a/cmd/lk/testdata/script/member-removal-no-keys.txtar +++ b/cmd/lk/testdata/script/member-removal-no-keys.txtar @@ -1,7 +1,9 @@ # Removing a member who owns no API keys goes ahead without asking: lk # confirms 0 keys, which is all the member owns. It leaves a count of 0 out -# of the request, which the API reads as 0. +# of the request, which the API reads as 0; a server from before the count +# answers an explicit one with a 500, as the second respond stands in for. respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 200 '{"numKeys":0}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2?confirmedNumKeys=0 500 '{"code":2, "message":"unknown field in field path confirmedNumKeys"}' exec lk --user-auth project member remove u_2 --project one ! stdout . stderr '^Removed member u_2$'