Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions cmd/lk/script_test.go
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})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testscript.Main copies the whole test binary to a temp bin/lk and puts it first on PATH for every test in cmd/lk. That includes the Windows runs in windows.yaml, where TestScripts always skips, so the copy is never used there, and any test in the package that runs lk from PATH gets the test binary. Skipping the setup where the scripts don't run:

Suggested change
testscript.Main(m, map[string]func(){"lk": main})
if runtime.GOOS == "windows" {
os.Exit(m.Run())
}
testscript.Main(m, map[string]func(){"lk": main})

Copy link
Copy Markdown
Contributor Author

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 runs lk from PATH gets the test binary. right now that's only the property test, which wants exactly that

}

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 + `"}`}
Comment thread
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)
}
23 changes: 23 additions & 0 deletions cmd/lk/testdata/script/both-auth-mode-flags.txtar
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
21 changes: 21 additions & 0 deletions cmd/lk/testdata/script/broken-default-user.txtar
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
Loading
Loading