Repository navigation
test(lk): run lk from scripts against a fake LiveKit Cloud #1069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2c3399b
test(lk): run lk from testscript scripts against a fake LiveKit Cloud
Topherhindman 0c5059f
test(lk): sign-in state and auth-mode scripts
Topherhindman e751e33
test(lk): Public API refusal script
Topherhindman 3310fe7
test(lk): project-resolution and per-server session scripts
Topherhindman e07a424
test(lk): key command scripts under user auth
Topherhindman f63fb44
test(lk): tighten the fake and record today's mistyped-project fallback
Topherhindman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 + `"}`} | ||
|
Topherhindman marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
testscript.Maincopies the whole test binary to a tempbin/lkand puts it first onPATHfor every test incmd/lk. That includes the Windows runs inwindows.yaml, whereTestScriptsalways skips, so the copy is never used there, and any test in the package that runslkfromPATHgets the test binary. Skipping the setup where the scripts don't run:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch for this pr on its own, but i'd rather keep it: #1077 drops the windows skip so the scripts run there too (windows ci is green on it), and #1072's property test finds lk with
exec.LookPath("lk"), so it needs testscript.Main to install it on windows as well. skipping the setup here would just get reverted two prs up. agree it means anything in cmd/lk that runslkfrom PATH gets the test binary. right now that's only the property test, which wants exactly that