diff --git a/cmd/lk/script_test.go b/cmd/lk/script_test.go new file mode 100644 index 00000000..dc77cfca --- /dev/null +++ b/cmd/lk/script_test.go @@ -0,0 +1,308 @@ +// 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 + +// The scripts in testdata/script run lk the way a user types it. Each is a +// txtar archive: a testscript script (see +// https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) followed by +// the files the run starts with. $HOME is $WORK/home, so a case's CLI config +// is its home/.livekit/cli-config.yaml file. +// +// `exec lk` runs this test binary as lk, through main, in a process of its +// own: lk's flags are package-level and cli/v3 keeps their parse state, so +// two runs can't share a process. +// +// Every script gets a fake LiveKit Cloud: one HTTP server, set as lk's proxy, +// that answers for any http://NAME.test host. Cases point lk at it by name, +// e.g. api_url: http://publicapi.test on a signed-in user, or url: +// ws://one.test on a configured project, so no expected output holds a port. +// Anything else lk sends through the proxy, such as the real Public API over +// https, is refused and logged. Go never proxies localhost or loopback +// addresses, so a request to one of those bypasses the fake and isn't logged. +// 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. +// requests +// Print every request the fake received, in order, with the +// credential it carried (a session token as bearer=, or as apikey= the +// API key that issued an access token, read from its claims without +// checking the signature) and the project it named. +// +// To regenerate expected output after an intended change, run +// +// UPDATE_LK_SCRIPTS=1 go test ./cmd/lk -run TestScripts -count=1 +// +// and review the diff. Every `cmp` whose expected file is in the archive is +// rewritten with what the run produced; regexp checks (stdout, stderr) are not. + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "testing" + + "github.com/livekit/protocol/auth" + "github.com/rogpeppe/go-internal/testscript" + + "github.com/livekit/livekit-cli/v2/pkg/public/oapi" +) + +func TestMain(m *testing.M) { + testscript.Main(m, map[string]func(){"lk": main}) +} + +func TestScripts(t *testing.T) { + if runtime.GOOS == "windows" { + // Three things stop the scripts on Windows: Go reports mode 0666 for + // every file there, so config.LoadOrCreate warns on each run that the + // config file should be 0600; testscript sets the home directory + // through USERPROFILE, which setupScript doesn't; and a Windows + // checkout writes the .txtar files with CRLF endings. + t.Skip("lk warns about the config file's permissions on every Windows run, and the scripts need USERPROFILE and LF checkouts") + } + testscript.Run(t, testscript.Params{ + Dir: filepath.Join("testdata", "script"), + Setup: setupScript, + Cmds: map[string]func(*testscript.TestScript, bool, []string){ + "respond": cmdRespond, + "requests": cmdRequests, + }, + UpdateScripts: os.Getenv("UPDATE_LK_SCRIPTS") != "", + RequireExplicitExec: true, + RequireUniqueNames: true, + }) +} + +// fakeCloudKey finds the script's fakeCloud in its testscript values. +type fakeCloudKey struct{} + +func setupScript(env *testscript.Env) error { + home := filepath.Join(env.WorkDir, "home") + env.Setenv("HOME", home) + // lk warns about a config file anyone else can read, and txtar files are + // written 0644. + err := os.Chmod(filepath.Join(home, ".livekit", "cli-config.yaml"), 0o600) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + + cloud := newFakeCloud() + env.Defer(cloud.Close) + env.Values[fakeCloudKey{}] = cloud + env.Setenv("HTTP_PROXY", cloud.URL) + env.Setenv("HTTPS_PROXY", cloud.URL) + // Under -race, lk would otherwise sleep a second before each clean exit. + env.Setenv("GORACE", strings.TrimSpace(env.Getenv("GORACE")+" atexit_sleep_ms=0")) + return nil +} + +// fakeCloud is an HTTP proxy that serves canned responses for any .test host +// and refuses everything else. It logs every request it receives. +type fakeCloud struct { + *httptest.Server + + mu sync.Mutex + responses map[string]cannedResponse // by "METHOD URL", without the query + log []string +} + +type cannedResponse struct { + status int + body string +} + +func newFakeCloud() *fakeCloud { + f := &fakeCloud{responses: map[string]cannedResponse{}} + f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) + return f +} + +// projectInPath finds the project id in a Public API path such as +// /v0/projects/p_1/keys or /v0/workspaces/w_1/projects/p_1. +var projectInPath = regexp.MustCompile(`/projects/([^/:]+)`) + +func (f *fakeCloud) serve(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + + // lk reaches a .test host through the proxy as plain HTTP. An https URL + // arrives as a CONNECT tunnel, which the fake never opens. + if r.Method == http.MethodConnect || !strings.HasSuffix(r.URL.Hostname(), ".test") { + f.log = append(f.log, fmt.Sprintf("%s %s refused: not a fake host", r.Method, r.Host)) + http.Error(w, "fake cloud: lk tried to reach "+r.Host, http.StatusForbidden) + return + } + + target := r.Method + " " + r.URL.Scheme + "://" + r.URL.Host + r.URL.Path + line := target + if token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { + if v, err := auth.ParseAPIToken(token); err == nil { + line += " apikey=" + v.APIKey() + } else { + line += " bearer=" + token + } + } + if m := projectInPath.FindStringSubmatch(r.URL.Path); m != nil { + line += " project=" + m[1] + } + f.log = append(f.log, line) + + res, ok := f.responses[target] + if !ok { + res = cannedResponse{http.StatusNotImplemented, `{"code":12,"message":"fake cloud: no response for ` + target + `"}`} + if strings.HasPrefix(r.URL.Path, "/twirp/") { + res.body = `{"code":"unimplemented","msg":"fake cloud: no response for ` + target + `"}` + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(res.status) + fmt.Fprint(w, res.body) +} + +// cmdRespond implements `respond METHOD URL STATUS [BODY]`. +func cmdRespond(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! respond") + } + if len(args) != 3 && len(args) != 4 { + ts.Fatalf("usage: respond METHOD URL STATUS [BODY]") + } + status, err := strconv.Atoi(args[2]) + ts.Check(err) + body := "" + if len(args) == 4 { + body = args[3] + } + ts.Check(ts.Value(fakeCloudKey{}).(*fakeCloud).respond(args[0]+" "+args[1], status, body)) +} + +// cmdRequests implements `requests`. +func cmdRequests(ts *testscript.TestScript, neg bool, args []string) { + if neg || len(args) != 0 { + ts.Fatalf("usage: requests") + } + // Taking Stdout even with nothing to print replaces the last command's + // stdout, so `! stdout .` checks that no request arrived. + stdout := ts.Stdout() + for _, line := range ts.Value(fakeCloudKey{}).(*fakeCloud).requests() { + fmt.Fprintln(stdout, line) + } +} + +// respond answers target ("METHOD URL") with status and body, once +// checkPublicAPIBody accepts the body. +func (f *fakeCloud) respond(target string, status int, body string) error { + method, rawURL, _ := strings.Cut(target, " ") + u, err := url.Parse(rawURL) + if err != nil { + return err + } + if err := checkPublicAPIBody(method, u.Path, status, body); err != nil { + return fmt.Errorf("respond %s %d: %w", target, status, err) + } + f.mu.Lock() + defer f.mu.Unlock() + f.responses[method+" "+u.Scheme+"://"+u.Host+u.Path] = cannedResponse{status, body} + return nil +} + +// publicAPIRoutes are the Public API routes the fake answers, each with the +// generated type of its 200 body. +var publicAPIRoutes = []struct { + method string + path *regexp.Regexp + body func() any +}{ + {http.MethodGet, regexp.MustCompile(`^/v0/projects$`), func() any { return new(oapi.LivekitPublicapiProjectsV1ListProjectsResponse) }}, + {http.MethodPatch, regexp.MustCompile(`^/v0/projects/[^/]+$`), func() any { return new(oapi.LivekitPublicapiProjectsV1UpdateProjectResponse) }}, + {http.MethodGet, regexp.MustCompile(`^/v0/projects/[^/]+/keys$`), func() any { return new(oapi.LivekitPublicapiProjectsV1ListKeysResponse) }}, + {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) }}, +} + +// middlewareError is the body the Public API's auth middleware and panic +// recovery send for a 401 or 500. The spec describes it only in prose, so +// it has no generated type; it goes away when they send a google.rpc.Status. +type middlewareError struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// checkPublicAPIBody checks a canned body for a Public API route (a /v0 +// path) against the client generated from the Public API's spec: a 2xx body +// must decode into the route's type in publicAPIRoutes, and an error body +// into a google.rpc.Status, or a middlewareError for a 401 or 500. Decoding +// is strict, so a field the spec doesn't have fails, and a regeneration that +// changes a type fails the scripts that send its old shape. +func checkPublicAPIBody(method, path string, status int, body string) error { + if !strings.HasPrefix(path, "/v0/") { + return nil + } + var probe struct { + Error json.RawMessage `json:"error"` + } + var v any + switch { + case (status == http.StatusUnauthorized || status == http.StatusInternalServerError) && json.Unmarshal([]byte(body), &probe) == nil && probe.Error != nil: + v = new(middlewareError) + case status >= 400: + v = new(oapi.GoogleRpcStatus) + default: + for _, r := range publicAPIRoutes { + if r.method == method && r.path.MatchString(path) { + v = r.body() + break + } + } + if v == nil { + return fmt.Errorf("no generated type for %s %s; add the route to publicAPIRoutes", method, path) + } + } + dec := json.NewDecoder(strings.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return fmt.Errorf("body doesn't match the spec's %T: %w", v, err) + } + if _, err := dec.Token(); err != io.EOF { + return fmt.Errorf("body has data after its JSON value") + } + return nil +} + +// requests returns the log of every request the fake received. +func (f *fakeCloud) requests() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.log) +} diff --git a/cmd/lk/testdata/script/both-auth-mode-flags.txtar b/cmd/lk/testdata/script/both-auth-mode-flags.txtar new file mode 100644 index 00000000..46a84265 --- /dev/null +++ b/cmd/lk/testdata/script/both-auth-mode-flags.txtar @@ -0,0 +1,23 @@ +# --user-auth and --legacy-auth together are an error; lk doesn't +# guess which mode was meant. +! exec lk --user-auth --legacy-auth project list +cmp stderr want-stderr +requests +! stdout . + +-- home/.livekit/cli-config.yaml -- +default_project: one +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test +-- want-stderr -- +option user-auth cannot be set along with option legacy-auth diff --git a/cmd/lk/testdata/script/broken-default-user.txtar b/cmd/lk/testdata/script/broken-default-user.txtar new file mode 100644 index 00000000..5d30f5da --- /dev/null +++ b/cmd/lk/testdata/script/broken-default-user.txtar @@ -0,0 +1,21 @@ +# A default user that no longer resolves is a clear error with the way to fix +# it, never a crash. Today the error says nobody is signed in to the default +# Public API, while another user is signed in elsewhere and the real problem +# is the default user. +! exec lk --user-auth project list +! stdout . +stderr 'run `lk cloud auth --user-auth` to sign in' +cp stderr lk.stderr +requests +! stdout . + +skip 'known bug: the error doesn''t say the default user no longer resolves' +grep 'gone@example.com' lk.stderr + +-- home/.livekit/cli-config.yaml -- +default_user: gone@example.com +users: + - id: u_2 + email: other@example.com + session_token: st_other + api_url: http://publicapi.test diff --git a/cmd/lk/testdata/script/default-project.txtar b/cmd/lk/testdata/script/default-project.txtar new file mode 100644 index 00000000..98a0fb20 --- /dev/null +++ b/cmd/lk/testdata/script/default-project.txtar @@ -0,0 +1,48 @@ +# With several projects and no --project, the configured default is used, in +# API-key mode and under --user-auth. +respond POST http://two.test/twirp/livekit.RoomService/ListRooms 200 '' +exec lk room list +cmp stderr want-api-key-stderr + +respond GET http://publicapi.test/v0/projects/p_2/members 200 '{"items":[]}' +exec lk --user-auth project member list +cmp stderr want-user-auth-stderr + +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_project: two +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone + - name: two + project_id: p_2 + url: ws://two.test + api_key: APItwo + api_secret: secrettwo +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + - project_id: p_2 + name: two + alias: two +-- want-api-key-stderr -- +Using project [two] +-- want-user-auth-stderr -- +Using user [dev@example.com] on [http://publicapi.test] +Using project [two] +No members found. +-- want-requests -- +POST http://two.test/twirp/livekit.RoomService/ListRooms apikey=APItwo +GET http://publicapi.test/v0/projects/p_2/members bearer=st_1 project=p_2 diff --git a/cmd/lk/testdata/script/env-keys-with-user-auth.txtar b/cmd/lk/testdata/script/env-keys-with-user-auth.txtar new file mode 100644 index 00000000..cd04ce1c --- /dev/null +++ b/cmd/lk/testdata/script/env-keys-with-user-auth.txtar @@ -0,0 +1,24 @@ +# --user-auth wins over API keys in the environment: the command runs +# as the signed-in user, with a one-line note that the env keys are ignored. +env LIVEKIT_URL=ws://env.test +env LIVEKIT_API_KEY=APIenv +env LIVEKIT_API_SECRET=secretenv +respond GET http://publicapi.test/v0/projects 200 '{"items":[{"id":"p_1","name":"one"}]}' +exec lk --user-auth project list +stdout 'p_1' +cp stderr lk.stderr +requests +cmp stdout want-requests + +skip 'known bug: --user-auth ignores LIVEKIT_API_KEY and LIVEKIT_API_SECRET without saying so' +grep 'LIVEKIT_API_KEY.*ignored' lk.stderr + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test +-- want-requests -- +GET http://publicapi.test/v0/projects bearer=st_1 diff --git a/cmd/lk/testdata/script/experimental-auth-alias.txtar b/cmd/lk/testdata/script/experimental-auth-alias.txtar new file mode 100644 index 00000000..85c0a84e --- /dev/null +++ b/cmd/lk/testdata/script/experimental-auth-alias.txtar @@ -0,0 +1,22 @@ +# --experimental-auth, the flag's old name, still selects user auth: it prints +# and sends exactly what --user-auth does. +respond GET http://publicapi.test/v0/projects 200 '{"items":[{"id":"p_1","name":"one"}]}' +exec lk --user-auth project list +cp stdout user-auth.stdout +cp stderr user-auth.stderr +exec lk --experimental-auth project list +cmp stdout user-auth.stdout +cmp stderr user-auth.stderr +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test +-- want-requests -- +GET http://publicapi.test/v0/projects bearer=st_1 +GET http://publicapi.test/v0/projects bearer=st_1 diff --git a/cmd/lk/testdata/script/expired-session.txtar b/cmd/lk/testdata/script/expired-session.txtar new file mode 100644 index 00000000..afcdef47 --- /dev/null +++ b/cmd/lk/testdata/script/expired-session.txtar @@ -0,0 +1,18 @@ +# A stored session past its expiry is refused before any call: lk says to sign +# in again and never sends the stale token. +! exec lk --user-auth project list +! stdout . +cmp stderr want-stderr +requests +! stdout . + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_expired + session_expiry: 1 + api_url: http://publicapi.test +-- want-stderr -- +session for dev@example.com has expired (run `lk cloud auth --user-auth --public-api-url http://publicapi.test` to sign in again) diff --git a/cmd/lk/testdata/script/key-commands-as-admin.txtar b/cmd/lk/testdata/script/key-commands-as-admin.txtar new file mode 100644 index 00000000..78b99998 --- /dev/null +++ b/cmd/lk/testdata/script/key-commands-as-admin.txtar @@ -0,0 +1,40 @@ +# A project ADMIN lists, creates and deletes the project's API keys. +respond GET http://publicapi.test/v0/projects/p_1/keys 200 '{"keys":[{"apiKey":"APIold","description":"ci"}]}' +exec lk --user-auth project key list --project one +cmp stdout want-list-stdout + +respond POST http://publicapi.test/v0/projects/p_1/keys 200 '{"apiKey":"APInew","secret":"secretnew"}' +exec lk --user-auth project key create --project one +cmp stdout want-create-stdout + +respond DELETE http://publicapi.test/v0/projects/p_1/keys/APIold 200 '{}' +exec lk --user-auth project key delete APIold --project one +stderr 'Revoked API key APIold' + +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 +-- want-list-stdout -- +┌─────────┬─────────────┬───────────────────┬─────────┐ +│ API Key │ Description │ Owner │ Created │ +├─────────┼─────────────┼───────────────────┼─────────┤ +│ APIold │ ci │ (service account) │ - │ +└─────────┴─────────────┴───────────────────┴─────────┘ +-- want-create-stdout -- +API Key: APInew +API Secret: secretnew +-- want-requests -- +GET http://publicapi.test/v0/projects/p_1/keys bearer=st_admin project=p_1 +POST http://publicapi.test/v0/projects/p_1/keys bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/keys/APIold bearer=st_admin project=p_1 diff --git a/cmd/lk/testdata/script/key-commands-refused.txtar b/cmd/lk/testdata/script/key-commands-refused.txtar new file mode 100644 index 00000000..c6a745b1 --- /dev/null +++ b/cmd/lk/testdata/script/key-commands-refused.txtar @@ -0,0 +1,25 @@ +# A member below ADMIN (READ or WRITE: the Public API refuses every key RPC +# below ADMIN with the same google.rpc.Status) gets the refusal with +# cloudAPIError's hint and a non-zero exit. lk never sees the role; the +# refusal is all it has. How the refusal reads is TestCloudAPIError's job. +respond GET http://publicapi.test/v0/projects/p_1/keys 403 '{"code":7,"message":"permission denied: this needs ADMIN on the project"}' +! exec lk --user-auth project key list --project one +! stdout . +stderr '^permission denied: this needs ADMIN on the project — .*re-run without `--user-auth`$' + +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_member +users: + - id: u_member + email: member@example.com + session_token: st_member + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one +-- want-requests -- +GET http://publicapi.test/v0/projects/p_1/keys bearer=st_member project=p_1 diff --git a/cmd/lk/testdata/script/livekit-toml-project.txtar b/cmd/lk/testdata/script/livekit-toml-project.txtar new file mode 100644 index 00000000..ab55beae --- /dev/null +++ b/cmd/lk/testdata/script/livekit-toml-project.txtar @@ -0,0 +1,27 @@ +# A livekit.toml in the working directory names the project by subdomain, and +# it wins over the default project. +respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 '' +respond POST http://two.test/twirp/livekit.RoomService/ListRooms 200 '' + +exec lk room list +requests +cmp stdout want-requests + +-- livekit.toml -- +[project] +subdomain = "one" +-- home/.livekit/cli-config.yaml -- +default_project: two +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone + - name: two + project_id: p_2 + url: ws://two.test + api_key: APItwo + api_secret: secrettwo +-- want-requests -- +POST http://one.test/twirp/livekit.RoomService/ListRooms apikey=APIone diff --git a/cmd/lk/testdata/script/mistyped-project.txtar b/cmd/lk/testdata/script/mistyped-project.txtar new file mode 100644 index 00000000..79b9fcb2 --- /dev/null +++ b/cmd/lk/testdata/script/mistyped-project.txtar @@ -0,0 +1,24 @@ +# In API-key mode, a --project that names no configured project is an error +# listing the configured ones. lk never falls back to the only project: the +# fake would answer that project's server, so lk can only fail by refusing. +# Today it does fall back; the checks before the skip record that. +respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 '' + +exec lk --project onee room list +stderr '^Using project \[one\]$' +requests +stdout '^POST http://one.test/twirp/livekit.RoomService/ListRooms apikey=APIone$' + +skip 'known bug: a --project that names no configured project falls back to the only one' +! exec lk --project onee room list +stderr 'project not found\. Available projects: one' +requests +! stdout . + +-- home/.livekit/cli-config.yaml -- +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone diff --git a/cmd/lk/testdata/script/no-default-project.txtar b/cmd/lk/testdata/script/no-default-project.txtar new file mode 100644 index 00000000..2eca3e4a --- /dev/null +++ b/cmd/lk/testdata/script/no-default-project.txtar @@ -0,0 +1,41 @@ +# With several projects, no default and no --project, a non-interactive run is +# a clear error, in API-key mode and under --user-auth. +! exec lk room list +cmp stderr want-api-key-stderr + +! exec lk --user-auth project member list +cmp stderr want-user-auth-stderr + +requests +! stdout . + +-- home/.livekit/cli-config.yaml -- +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone + - name: two + project_id: p_2 + url: ws://two.test + api_key: APItwo + api_secret: secrettwo +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + - project_id: p_2 + name: two + alias: two +-- want-api-key-stderr -- +multiple projects configured; set --project in non-interactive mode +-- want-user-auth-stderr -- +Using user [dev@example.com] on [http://publicapi.test] +multiple projects available; set --project in non-interactive mode diff --git a/cmd/lk/testdata/script/no-session.txtar b/cmd/lk/testdata/script/no-session.txtar new file mode 100644 index 00000000..44c875e1 --- /dev/null +++ b/cmd/lk/testdata/script/no-session.txtar @@ -0,0 +1,9 @@ +# With no signed-in user, a session-only command says to sign in and fails +# before it calls the Public API. +! exec lk --user-auth project list +! stdout . +cmp stderr want-stderr +requests +! stdout . +-- want-stderr -- +no user is signed in (run `lk cloud auth --user-auth` to sign in) diff --git a/cmd/lk/testdata/script/not-yet-under-user-auth.txtar b/cmd/lk/testdata/script/not-yet-under-user-auth.txtar new file mode 100644 index 00000000..9a85ccc3 --- /dev/null +++ b/cmd/lk/testdata/script/not-yet-under-user-auth.txtar @@ -0,0 +1,24 @@ +# A command that doesn't support user auth yet says so under +# --user-auth, rather than falling back to an API key. +! exec lk --user-auth agent simulate text +! stdout . +cmp stderr want-stderr +requests +! stdout . + +-- home/.livekit/cli-config.yaml -- +default_project: one +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test +-- want-stderr -- +this command is not yet available under --user-auth (user-based auth); re-run without it to use API-key authentication diff --git a/cmd/lk/testdata/script/only-user-project.txtar b/cmd/lk/testdata/script/only-user-project.txtar new file mode 100644 index 00000000..dc6e2bb0 --- /dev/null +++ b/cmd/lk/testdata/script/only-user-project.txtar @@ -0,0 +1,20 @@ +# Under --user-auth, with no --project and no default project, a user who can +# reach only one project acts on it without being asked. +respond GET http://publicapi.test/v0/projects/p_1/keys 200 '{}' + +exec lk --user-auth project key list +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one +-- want-requests -- +GET http://publicapi.test/v0/projects/p_1/keys bearer=st_1 project=p_1 diff --git a/cmd/lk/testdata/script/project-beats-env-credentials.txtar b/cmd/lk/testdata/script/project-beats-env-credentials.txtar new file mode 100644 index 00000000..e2fd42a2 --- /dev/null +++ b/cmd/lk/testdata/script/project-beats-env-credentials.txtar @@ -0,0 +1,21 @@ +# --project wins over credentials in the environment: the request goes to the +# named project's server, with a token issued by that project's key. +env LIVEKIT_URL=ws://env.test +env LIVEKIT_API_KEY=APIenv +env LIVEKIT_API_SECRET=secretenv +respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 '' +respond POST http://env.test/twirp/livekit.RoomService/ListRooms 200 '' + +exec lk --project one room list +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone +-- want-requests -- +POST http://one.test/twirp/livekit.RoomService/ListRooms apikey=APIone diff --git a/cmd/lk/testdata/script/public-api-refusals.txtar b/cmd/lk/testdata/script/public-api-refusals.txtar new file mode 100644 index 00000000..335c065b --- /dev/null +++ b/cmd/lk/testdata/script/public-api-refusals.txtar @@ -0,0 +1,27 @@ +# A Public API refusal fails with a non-zero exit, carries cloudAPIError's +# hint, and names the project it was about. How each refusal reads is +# TestCloudAPIError's job. The body is the Public API's own google.rpc.Status. +respond PATCH http://publicapi.test/v0/projects/p_1 403 '{"code":7,"message":"permission denied: this needs ADMIN on the project"}' +! exec lk --user-auth project update one --name renamed +! stdout . +stderr '^permission denied: this needs ADMIN on the project — .*re-run without `--user-auth`$' +cp stderr lk.stderr +requests +cmp stdout want-requests + +skip 'known bug: lk does not name the project when the Public API refuses a call' +grep 'p_1\b|\[one\]' lk.stderr + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one +-- want-requests -- +PATCH http://publicapi.test/v0/projects/p_1 bearer=st_1 project=p_1 diff --git a/cmd/lk/testdata/script/rejected-session.txtar b/cmd/lk/testdata/script/rejected-session.txtar new file mode 100644 index 00000000..436e5889 --- /dev/null +++ b/cmd/lk/testdata/script/rejected-session.txtar @@ -0,0 +1,26 @@ +# A session the Public API rejects (signed out, revoked) fails as +# Unauthenticated with a hint to sign in again. The 401 body is the one the +# Public API's auth middleware sends. +respond GET http://publicapi.test/v0/projects 401 '{"error":{"code":"unauthenticated","message":"authentication required"}}' +! exec lk --user-auth project list +! stdout . +stderr 'to sign in again\)$' +cp stderr lk.stderr +requests +cmp stdout want-requests + +skip 'known bug: lk shows the raw body of the Public API''s 401 instead of its message' +cmp lk.stderr want-stderr + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_rejected + api_url: http://publicapi.test +-- want-stderr -- +Using user [dev@example.com] on [http://publicapi.test] +authentication required (run `lk cloud auth --user-auth --public-api-url http://publicapi.test` to sign in again) +-- want-requests -- +GET http://publicapi.test/v0/projects bearer=st_rejected diff --git a/cmd/lk/testdata/script/session-only-in-legacy-mode.txtar b/cmd/lk/testdata/script/session-only-in-legacy-mode.txtar new file mode 100644 index 00000000..a0bb9381 --- /dev/null +++ b/cmd/lk/testdata/script/session-only-in-legacy-mode.txtar @@ -0,0 +1,18 @@ +# A command that only exists under user auth says so in API-key mode, without +# calling anything. +! exec lk project update one --name renamed +! stdout . +cmp stderr want-stderr +requests +! stdout . + +-- home/.livekit/cli-config.yaml -- +default_project: one +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone +-- want-stderr -- +this command is only available under --user-auth (user-based auth) diff --git a/cmd/lk/testdata/script/staging-and-prod-sessions.txtar b/cmd/lk/testdata/script/staging-and-prod-sessions.txtar new file mode 100644 index 00000000..af0fbc29 --- /dev/null +++ b/cmd/lk/testdata/script/staging-and-prod-sessions.txtar @@ -0,0 +1,30 @@ +# One email signed in to staging and to prod keeps both sessions, and each +# command sends the session for the server it talks to: a staging token never +# goes to prod. +respond GET http://staging.test/v0/projects 200 '{"items":[]}' +respond GET http://prod.test/v0/projects 200 '{"items":[]}' + +env LIVEKIT_API_URL=http://staging.test +exec lk --user-auth project list +env LIVEKIT_API_URL=http://prod.test +exec lk --user-auth project list + +requests +cmp stdout want-requests +grep 'session_token: st_staging' home/.livekit/cli-config.yaml +grep 'session_token: st_prod' home/.livekit/cli-config.yaml + +-- home/.livekit/cli-config.yaml -- +default_user: u_1 +users: + - id: u_1 + email: dev@example.com + session_token: st_staging + api_url: http://staging.test + - id: u_1 + email: dev@example.com + session_token: st_prod + api_url: http://prod.test +-- want-requests -- +GET http://staging.test/v0/projects bearer=st_staging +GET http://prod.test/v0/projects bearer=st_prod diff --git a/cmd/lk/testdata/script/subdomain.txtar b/cmd/lk/testdata/script/subdomain.txtar new file mode 100644 index 00000000..de327f63 --- /dev/null +++ b/cmd/lk/testdata/script/subdomain.txtar @@ -0,0 +1,24 @@ +# --subdomain picks the configured project whose URL has that subdomain, over +# the default project. +respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 '' +respond POST http://two.test/twirp/livekit.RoomService/ListRooms 200 '' + +exec lk --subdomain one room list +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_project: two +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone + - name: two + project_id: p_2 + url: ws://two.test + api_key: APItwo + api_secret: secrettwo +-- want-requests -- +POST http://one.test/twirp/livekit.RoomService/ListRooms apikey=APIone diff --git a/cmd/lk/testdata/script/two-users-same-api.txtar b/cmd/lk/testdata/script/two-users-same-api.txtar new file mode 100644 index 00000000..3e6abb45 --- /dev/null +++ b/cmd/lk/testdata/script/two-users-same-api.txtar @@ -0,0 +1,32 @@ +# Two users signed in to the same Public API with no default user: lk can't +# guess which one acts, so it refuses without sending either session, and +# says to pick one with --user. --user picks one, and only that user's +# session is sent. +env LIVEKIT_API_URL=http://publicapi.test +respond GET http://publicapi.test/v0/projects 200 '{"items":[]}' + +! exec lk --user-auth project list +! stdout . +cp stderr refused.stderr +requests +! stdout . + +exec lk --user-auth --user ops@example.com project list +requests +cmp stdout want-requests + +skip 'known bug: with two users signed in, the error says no user is signed in instead of asking for --user' +grep 'pass --user' refused.stderr + +-- home/.livekit/cli-config.yaml -- +users: + - id: u_1 + email: dev@example.com + session_token: st_1 + api_url: http://publicapi.test + - id: u_3 + email: ops@example.com + session_token: st_3 + api_url: http://publicapi.test +-- want-requests -- +GET http://publicapi.test/v0/projects bearer=st_3 diff --git a/cmd/lk/user_auth_test.go b/cmd/lk/user_auth_test.go index 382a3df9..164e83ef 100644 --- a/cmd/lk/user_auth_test.go +++ b/cmd/lk/user_auth_test.go @@ -16,6 +16,8 @@ package main import ( "context" + "errors" + "net/http" "strings" "testing" @@ -229,6 +231,38 @@ func TestSignInHint(t *testing.T) { "the hint follows the API the default user signed in to") } +// TestCloudAPIError checks how a Public API refusal reads once a command +// passes it through cloudAPIError. The scripts only check that commands do. +func TestCloudAPIError(t *testing.T) { + prev := publicAPIURL + t.Cleanup(func() { publicAPIURL = prev }) + publicAPIURL = public.DefaultBaseURL + + plain := errors.New("connection refused") + for _, tc := range []struct { + name string + err error + want string + }{ + {"unauthenticated says how to sign in again", + &public.APIError{Status: http.StatusUnauthorized, Message: "authentication required"}, + "authentication required (run `lk cloud auth --user-auth` to sign in again)"}, + {"permission denied says why and what to do instead", + &public.APIError{Status: http.StatusForbidden, Message: "permission denied: this needs ADMIN on the project"}, + "permission denied: this needs ADMIN on the project — your account doesn't have access to this project or action. To act with API-key credentials instead, re-run without `--user-auth`"}, + {"not found passes through", + &public.APIError{Status: http.StatusNotFound, Message: "project not found"}, + "project not found"}, + {"other errors pass through", plain, "connection refused"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := cloudAPIError(tc.err) + assert.EqualError(t, err, tc.want) + assert.ErrorIs(t, err, tc.err) + }) + } +} + func TestSetUserAuthHidden(t *testing.T) { root := &cli.Command{Flags: globalFlags} for _, cmds := range [][]*cli.Command{ProjectCommands, WorkspaceCommands, UserCommands, CloudCommands, AgentCommands} { diff --git a/go.mod b/go.mod index 3bf837a4..b9091a3e 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,8 @@ module github.com/livekit/livekit-cli/v2 go 1.26.8 - toolchain go1.26.10 + require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 @@ -203,6 +203,7 @@ require ( github.com/prometheus/procfs v0.22.0 // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.15.0 github.com/sajari/fuzzy v1.0.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect