Skip to content

test(lk): run lk from scripts against a fake LiveKit Cloud - #1069

Merged
Topherhindman merged 6 commits into
mainfrom
auth-tests/lk-auth-scripts
Oct 23, 2026
Merged

Topherhindman merged 6 commits into
mainfrom
auth-tests/lk-auth-scripts

Conversation

@Topherhindman

@Topherhindman Topherhindman commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Why

lk has two auth modes now, and nothing tested the CLI the way a user types it: root flags, env vars, the config file and the stored session together deciding which credential and which project a command uses. These scripts pin that behaviour so a change to the resolver shows up as a failing script, not a user acting on the wrong project.

What changed

  • cmd/lk/script_test.go: a testscript (rogpeppe/go-internal) harness. Each cmd/lk/testdata/script/*.txtar runs lk as its own process, with real args and env and a crafted CLI config under a temporary $HOME, and checks stdout, stderr and the exit code. One process per case is needed because 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 (respond) and records the credential and project of every request (requests). Anything else lk sends through the proxy is refused and logged, so it shows up in the request log. UPDATE_LK_SCRIPTS=1 regenerates 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 (chore(public): regenerate the Public API client #1078), 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 from the auth middleware and panic recovery are described only in prose, so they get a small type of their own until they become a google.rpc.Status.
  • 21 scripts:
    • sign-in states, mode mismatches, the --experimental-auth alias and both mode flags at once;
    • a Public API 401 and 403, with the bodies the Public API really sends: lk exits non-zero with the right hint;
    • project resolution: --project over env credentials, --subdomain, a livekit.toml, the default project, no default, and a mistyped --project;
    • sessions: staging and prod for one email, two users on one API with and without --user, and a user's only project picked automatically;
    • key commands as ADMIN and as a refused member.
  • TestCloudAPIError: how each refusal reads (the sign-in hint on Unauthenticated, why and what to do on PermissionDenied, NotFound passed through) is one function, so it's a unit test. The scripts only check that commands attach it.
  • Why not the VRT goldens? They snapshot TUI frames rendered in-process. These tests need a whole lk run per case: real args, env, a config file under a temporary $HOME, the requests lk sends, and the exit code, each in its own process since lk's flags keep parse state. testscript gives that with one readable file per case, and follows the VRT convention for regenerating (UPDATE_LK_SCRIPTS).
  • Six scripts check what holds today and skip by name at a known bug:
    • the 401 shows raw JSON;
    • env API keys are ignored without saying so;
    • a default user that no longer resolves isn't named;
    • two users on one API get "no user signed in" instead of being asked for --user;
    • a mistyped --project falls back to the only project;
    • refusals don't name the project. This one has no fix yet; it needs the project id threaded through the Public API client.
  • The scripts skip on Windows: lk warns there that the config file should be 0600 on every run, testscript sets the home directory through USERPROFILE, and a Windows checkout gives the .txtar files CRLF endings.

Testing

  • go build ./..., go vet ./..., go test -count=1 ./..., go test -race ./cmd/lk and golangci-lint all pass, and each commit builds and passes on its own.
  • -race time for cmd/lk goes from 29.7s to 32.0s.
  • 15 scripts pass and 6 skip. For each skip, the checks after it fail today for the reason it names.
  • I planted 17 bugs in lk's credential and project resolution, one at a time. These scripts catch 10 of them; the stacked property test catches the other 7.

Stacked on this

  • A property test over every credential input.
  • Five small fixes. Four delete the skips above (one of them deletes two); the fifth stops the permissions warning on Windows and runs the scripts there.

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.
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.
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.
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.
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.
@Topherhindman
Topherhindman force-pushed the auth-tests/lk-auth-scripts branch from 7d625e4 to e07a424 Compare October 23, 2026 17:53

@u9g u9g left a comment

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.

Cool changes 👍

Comment thread cmd/lk/testdata/script/mistyped-project.txtar
Comment thread cmd/lk/script_test.go Outdated
Comment thread cmd/lk/script_test.go Outdated
Comment thread cmd/lk/script_test.go Outdated
Comment thread cmd/lk/script_test.go
)

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

Comment thread cmd/lk/script_test.go Outdated
Comment thread cmd/lk/script_test.go
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.
@Topherhindman
Topherhindman merged commit 678abb0 into main Oct 23, 2026
26 checks passed
@Topherhindman
Topherhindman deleted the auth-tests/lk-auth-scripts branch October 23, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants