From 2c3399bbc601de75a732063f652f6347447703dc Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Thu, 22 Oct 2026 15:03:00 -0700 Subject: [PATCH 1/6] test(lk): run lk from testscript scripts against a fake LiveKit Cloud Each script in cmd/lk/testdata/script runs lk the way a user types it: real args and env, a crafted CLI config under a temporary $HOME, and the stdout, stderr and exit code the run produces. testscript (rogpeppe/go-internal) re-executes the test binary as lk in a process per case, since lk's flags are package-level and cli/v3 keeps their parse state. A fake LiveKit Cloud, one httptest server set as lk's HTTP proxy, answers any http://NAME.test host with canned responses and records the credential and project each request carried, so a case can assert on what reached the server as well as on the output. Anything else lk sends through the proxy is refused and logged, so it shows up in the request log. UPDATE_LK_SCRIPTS=1 regenerates the expected files. A canned Public API body has to be what the spec says its route sends. The fake decodes it strictly into the type the client is generated with, or into a google.rpc.Status for an error, so a script can't drift from the spec, and a regeneration that changes a shape fails the scripts still sending the old one. The 401 and 500 bodies the auth middleware and panic recovery send are described only in prose, so they get a small type of their own. The first row: with no stored session, a session-only command says to sign in and exits non-zero. --- cmd/lk/script_test.go | 301 ++++++++++++++++++++++++ cmd/lk/testdata/script/no-session.txtar | 9 + go.mod | 3 +- 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 cmd/lk/script_test.go create mode 100644 cmd/lk/testdata/script/no-session.txtar diff --git a/cmd/lk/script_test.go b/cmd/lk/script_test.go new file mode 100644 index 00000000..6356005f --- /dev/null +++ b/cmd/lk/script_test.go @@ -0,0 +1,301 @@ +// 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/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", "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 + `"}`} + } + 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[target] = 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 v any + switch { + case (status == http.StatusUnauthorized || status == http.StatusInternalServerError) && strings.HasPrefix(body, `{"error"`): + 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 dec.More() { + 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/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/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 From 0c5059f10519078bfc463b86476d2109c9f27ac3 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Wed, 21 Oct 2026 19:58:52 -0700 Subject: [PATCH 2/6] test(lk): sign-in state and auth-mode scripts An expired session is refused before any call, and a session the Public API rejects fails as Unauthenticated; both say how to sign in again. A session-only command in API-key mode, and a command not yet available under --user-auth, each say which mode they need. --experimental-auth still selects user auth, and passing both mode flags is an error. Three rows check what holds today and skip at the check that doesn't: with API keys in the environment lk doesn't say --user-auth ignores them, the 401 the Public API sends is shown as raw JSON, and the error for a default user that no longer resolves doesn't say so. --- .../script/both-auth-mode-flags.txtar | 23 ++++++++++++++++ .../testdata/script/broken-default-user.txtar | 21 +++++++++++++++ .../script/env-keys-with-user-auth.txtar | 24 +++++++++++++++++ .../script/experimental-auth-alias.txtar | 22 ++++++++++++++++ cmd/lk/testdata/script/expired-session.txtar | 18 +++++++++++++ .../script/not-yet-under-user-auth.txtar | 24 +++++++++++++++++ cmd/lk/testdata/script/rejected-session.txtar | 26 +++++++++++++++++++ .../script/session-only-in-legacy-mode.txtar | 18 +++++++++++++ 8 files changed, 176 insertions(+) create mode 100644 cmd/lk/testdata/script/both-auth-mode-flags.txtar create mode 100644 cmd/lk/testdata/script/broken-default-user.txtar create mode 100644 cmd/lk/testdata/script/env-keys-with-user-auth.txtar create mode 100644 cmd/lk/testdata/script/experimental-auth-alias.txtar create mode 100644 cmd/lk/testdata/script/expired-session.txtar create mode 100644 cmd/lk/testdata/script/not-yet-under-user-auth.txtar create mode 100644 cmd/lk/testdata/script/rejected-session.txtar create mode 100644 cmd/lk/testdata/script/session-only-in-legacy-mode.txtar 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/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/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/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) From e751e33ad89d1e1519b306d8bea07f75a9f3674c Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Wed, 21 Oct 2026 19:58:52 -0700 Subject: [PATCH 3/6] test(lk): Public API refusal script A PermissionDenied from the Public API, with the body it really sends, fails with a non-zero exit and carries cloudAPIError's hint. The check that the message also names the project is skipped as a known bug. How each refusal reads (the sign-in hint on Unauthenticated, why and what to do instead on PermissionDenied, NotFound passed through) is a unit test of cloudAPIError, since that is one function and needs no process. --- .../testdata/script/public-api-refusals.txtar | 27 +++++++++++++++ cmd/lk/user_auth_test.go | 34 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 cmd/lk/testdata/script/public-api-refusals.txtar 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/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} { From 3310fe7e0a0d0a0462d70b1f5b761a3d92a7d3b8 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Thu, 22 Oct 2026 15:03:00 -0700 Subject: [PATCH 4/6] test(lk): project-resolution and per-server session scripts API-key mode: --project beats credentials in the environment, --subdomain and a livekit.toml each pick their project over the default, the default is used when nothing else names one, and no default is a clear error in non-interactive mode. User auth: the default project is used, a user who can reach only one project acts on it, and one email signed in to staging and to prod keeps both sessions, with each command sending the session for the server it talks to. Two users on the same Public API with no default is a refusal; --user picks one. Two rows skip as known bugs: a --project that names no configured project falls back to the only project instead of failing, and the two-users refusal says no user is signed in instead of asking for --user. --- cmd/lk/testdata/script/default-project.txtar | 48 +++++++++++++++++++ .../script/livekit-toml-project.txtar | 27 +++++++++++ cmd/lk/testdata/script/mistyped-project.txtar | 18 +++++++ .../testdata/script/no-default-project.txtar | 41 ++++++++++++++++ .../testdata/script/only-user-project.txtar | 20 ++++++++ .../project-beats-env-credentials.txtar | 21 ++++++++ .../script/staging-and-prod-sessions.txtar | 30 ++++++++++++ cmd/lk/testdata/script/subdomain.txtar | 24 ++++++++++ .../testdata/script/two-users-same-api.txtar | 32 +++++++++++++ 9 files changed, 261 insertions(+) create mode 100644 cmd/lk/testdata/script/default-project.txtar create mode 100644 cmd/lk/testdata/script/livekit-toml-project.txtar create mode 100644 cmd/lk/testdata/script/mistyped-project.txtar create mode 100644 cmd/lk/testdata/script/no-default-project.txtar create mode 100644 cmd/lk/testdata/script/only-user-project.txtar create mode 100644 cmd/lk/testdata/script/project-beats-env-credentials.txtar create mode 100644 cmd/lk/testdata/script/staging-and-prod-sessions.txtar create mode 100644 cmd/lk/testdata/script/subdomain.txtar create mode 100644 cmd/lk/testdata/script/two-users-same-api.txtar 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/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..e2a4f9e6 --- /dev/null +++ b/cmd/lk/testdata/script/mistyped-project.txtar @@ -0,0 +1,18 @@ +# 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. +respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 '' + +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/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/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 From e07a42471537f15a8915e6b30a24426d3424efa8 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Wed, 21 Oct 2026 19:58:52 -0700 Subject: [PATCH 5/6] test(lk): key command scripts under user auth A project ADMIN lists, creates and deletes the project's API keys. A member below ADMIN listing them gets the refusal with cloudAPIError's hint and a non-zero exit. lk never sees the role, only the Public API's refusal, which is the same for READ and WRITE, so one refused identity covers both. --- .../script/key-commands-as-admin.txtar | 40 +++++++++++++++++++ .../script/key-commands-refused.txtar | 25 ++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 cmd/lk/testdata/script/key-commands-as-admin.txtar create mode 100644 cmd/lk/testdata/script/key-commands-refused.txtar 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 From f63fb44f997db852f4ac01acfd552a82500b3999 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Fri, 23 Oct 2026 11:58:26 -0700 Subject: [PATCH 6/6] test(lk): tighten the fake and record today's mistyped-project fallback mistyped-project skipped before running lk, so it checked nothing. It now records today's fallback before the skip: lk says it's using project one and sends one's key, so the fix shows up as a diff there. The fake: - keys a canned response the way it looks one up, without the query, as the respond docs say; - rejects a Public API body with anything after its JSON value, such as an extra closing brace, which dec.More let through; - recognises the middleware's 401/500 body by its "error" field rather than by the bytes it starts with; - keeps the caller's GORACE and appends atexit_sleep_ms=0 to it; - answers a missing Twirp response with a Twirp error, so lk shows which response the script didn't give. --- cmd/lk/script_test.go | 15 +++++++++++---- cmd/lk/testdata/script/mistyped-project.txtar | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/cmd/lk/script_test.go b/cmd/lk/script_test.go index 6356005f..dc77cfca 100644 --- a/cmd/lk/script_test.go +++ b/cmd/lk/script_test.go @@ -54,6 +54,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "net/http" "net/http/httptest" @@ -119,7 +120,7 @@ func setupScript(env *testscript.Env) error { 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", "atexit_sleep_ms=0") + env.Setenv("GORACE", strings.TrimSpace(env.Getenv("GORACE")+" atexit_sleep_ms=0")) return nil } @@ -177,6 +178,9 @@ func (f *fakeCloud) serve(w http.ResponseWriter, r *http.Request) { 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) @@ -226,7 +230,7 @@ func (f *fakeCloud) respond(target string, status int, body string) error { } f.mu.Lock() defer f.mu.Unlock() - f.responses[target] = cannedResponse{status, body} + f.responses[method+" "+u.Scheme+"://"+u.Host+u.Path] = cannedResponse{status, body} return nil } @@ -265,9 +269,12 @@ 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) && strings.HasPrefix(body, `{"error"`): + 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) @@ -287,7 +294,7 @@ func checkPublicAPIBody(method, path string, status int, body string) error { if err := dec.Decode(v); err != nil { return fmt.Errorf("body doesn't match the spec's %T: %w", v, err) } - if dec.More() { + if _, err := dec.Token(); err != io.EOF { return fmt.Errorf("body has data after its JSON value") } return nil diff --git a/cmd/lk/testdata/script/mistyped-project.txtar b/cmd/lk/testdata/script/mistyped-project.txtar index e2a4f9e6..79b9fcb2 100644 --- a/cmd/lk/testdata/script/mistyped-project.txtar +++ b/cmd/lk/testdata/script/mistyped-project.txtar @@ -1,8 +1,14 @@ # 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'