Files
llmgw/internal/web/web_test.go
T
Ilker ManapandClaude Opus 5 216fc9bb03 Let people sign up, be approved, and issue their own keys
Every key needed an operator with a shell. This adds the login system that
made that necessary, which is the thing I argued against building until it was
asked for — so it is built as if it will be attacked, because a public login
form is.

Signing up produces an account that can do nothing. Approval is what makes it
work and stays on the CLI: having a shell on the control plane is what makes
someone an administrator, and that is a smaller thing to get right than roles
and an admin UI that mints credentials.

The details that matter:

Passwords are argon2id, where a slow hash is correct — the input is chosen by
a human and has a dictionary, which is the opposite of an API key. Both kinds
of authentication now live in this codebase and they are in separate packages
so neither set of assumptions leaks into the other. Length is the only rule:
composition rules push people towards Password1! and cost more entropy than
they buy.

Signup never says whether an address already has an account, and sign-in
answers the same way for a wrong password and a missing account — verifying
against a dummy hash when there is no user, so a stopwatch cannot enumerate
accounts. Account state is checked after the password, not before, so guessing
an address reveals nothing about it. Guessing is bounded per address rather
than per IP: an attacker has many addresses to come from and one to guess
against, and limiting by IP locks out an office behind one NAT.

Sessions are server-side, so signing out and suspending take effect
immediately rather than whenever a token expires; suspension is checked in the
same query that resolves the session, because a second round trip is a check
somebody eventually forgets. Every state-changing form carries a CSRF token
verified against a hash in the session — a cookie authenticates the browser,
not the page that asked.

Key ownership is enforced in the UPDATE rather than by reading first, so a
guessed key id from another account changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:32:35 +02:00

323 lines
11 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func get(t *testing.T, h http.Handler, target string, headers map[string]string) (int, string) {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
for k, v := range headers {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code, w.Body.String()
}
func TestLandingPageTellsYouHowToConnect(t *testing.T) {
code, body := get(t, Landing("v1.2.3", true, false, "", ""), "http://llmgw.example/", nil)
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
for _, want := range []string{"http://llmgw.example/v1", "Authorization: Bearer", "v1.2.3"} {
if !strings.Contains(body, want) {
t.Errorf("the page does not mention %q", want)
}
}
}
func TestLandingPageSaysNothingAboutWhatIsHere(t *testing.T) {
// It is served without a key, to anyone who can reach the address. Anything
// on it is public, so nothing about the cluster belongs on it.
_, body := get(t, Landing("v1", true, false, "", ""), "http://llmgw.example/", nil)
for _, leak := range []string{"llama", "qwen", "10.0.0.", "replica", "node01"} {
if strings.Contains(strings.ToLower(body), leak) {
t.Errorf("the landing page leaks %q", leak)
}
}
}
func TestLandingPageWarnsWhenTheGatewayIsOpen(t *testing.T) {
// Someone who reaches this page on an unauthenticated gateway should learn
// that from the page rather than by discovering they can use it.
_, open := get(t, Landing("v1", false, false, "", ""), "http://llmgw.example/", nil)
if !strings.Contains(open, "no API keys configured") {
t.Error("an open gateway does not say so")
}
_, closed := get(t, Landing("v1", true, false, "", ""), "http://llmgw.example/", nil)
if strings.Contains(closed, "no API keys configured") {
t.Error("a closed gateway claims to be open")
}
}
func TestTheExampleURLSurvivesAReverseProxy(t *testing.T) {
// The process sees http and an internal host; the client used https and a
// public name. An example naming the internal address is one nobody can
// paste anywhere.
_, body := get(t, Landing("v1", true, false, "", ""), "http://10.0.0.140:8080/", map[string]string{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "llmgw.mynodes.xyz",
})
if !strings.Contains(body, "https://llmgw.mynodes.xyz/v1") {
t.Error("the example does not use the address the client actually used")
}
if strings.Contains(body, "10.0.0.140") {
t.Error("the internal address leaked into the page")
}
}
func TestForwardedHeaderChainsTakeTheFirstEntry(t *testing.T) {
// A chain of proxies appends rather than replaces.
_, body := get(t, Landing("v1", true, false, "", ""), "http://internal/", map[string]string{
"X-Forwarded-Proto": "https, http",
"X-Forwarded-Host": "llmgw.example, internal",
})
if !strings.Contains(body, "https://llmgw.example/v1") {
t.Errorf("the base URL was built from the wrong entry")
}
}
func TestOnlyTheRootIsTheLandingPage(t *testing.T) {
// A catch-all would turn every mistyped path into this page, hiding real
// 404s from anyone debugging a client.
code, _ := get(t, Landing("v1", true, false, "", ""), "http://x/v1/chat/completionz", nil)
if code != http.StatusNotFound {
t.Errorf("a mistyped path returned %d", code)
}
}
// fakeSource is a cluster for the dashboard to render.
type fakeSource struct{}
func (fakeSource) Nodes() []adminapi.NodeView {
return []adminapi.NodeView{{
ID: "llmgw-node01", State: "ready", GPUTotal: 8, GPUFree: 4,
Classes: []string{"h100-80g"}, Fabrics: []string{"ib-a"},
Replicas: 1, LastSeenAgo: "2s", AgentVersion: "v0.5.0",
Unhealthy: []string{"gpu 3: ECC errors"},
}}
}
func (fakeSource) Models() []adminapi.ModelView {
return []adminapi.ModelView{
{Name: "served", Ready: 1, Min: 1, Max: 2, Profiles: []string{"tp4"}},
{Name: "stuck", Ready: 0, Min: 1, Max: 1, Profiles: []string{"tp8"}},
}
}
func (fakeSource) Replicas(string) []adminapi.ReplicaView {
return []adminapi.ReplicaView{{
ID: "served-1", Model: "served", State: "ready", Pool: "ib-a/h100-80g",
Endpoint: "http://10.0.0.141:20000", AgeSec: 3600,
Shards: []adminapi.ShardView{{ID: "served-1-r0", Node: "llmgw-node01"}},
}}
}
func (fakeSource) Explain(model string) adminapi.ExplainView {
if model != "stuck" {
return adminapi.ExplainView{Model: model, Placed: true}
}
return adminapi.ExplainView{Model: "stuck", Rejections: []adminapi.RejectionView{
{Profile: "tp8", Node: "llmgw-node01", Reason: "has 4 free h100-80g GPUs, needs 8"},
}}
}
type fakeUsage struct{ err error }
func (f fakeUsage) UsageSince(context.Context, time.Time) ([]store.KeyUsage, error) {
if f.err != nil {
return nil, f.err
}
return []store.KeyUsage{{
KeyID: "key-1", KeyName: "laptop", KeyPrefix: "sk-llmgw-ab12…",
ProjectID: "chat", Requests: 42, Errors: 2,
Prompt: 1000, Completion: 3000, CostMicros: 4_500_000,
LastAt: time.Now().Add(-time.Minute),
}}, nil
}
func TestDashboardShowsTheCluster(t *testing.T) {
h := Dashboard(fakeSource{}, fakeUsage{}, "v1", time.Second, nil)
code, body := get(t, h, "http://127.0.0.1:9090/", nil)
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
for _, want := range []string{
"llmgw-node01", "h100-80g", "served-1", "http://10.0.0.141:20000",
"ECC errors", "laptop", "4.50", // spend shown in units, not micro-units
} {
if !strings.Contains(body, want) {
t.Errorf("the dashboard does not show %q", want)
}
}
}
func TestDashboardExplainsOnlyWhatIsUnplaced(t *testing.T) {
// Asking why a satisfied model was not placed gives an empty answer, and a
// page full of empty answers hides the one that matters.
h := Dashboard(fakeSource{}, nil, "v1", time.Second, nil)
_, body := get(t, h, "http://127.0.0.1:9090/", nil)
if !strings.Contains(body, "needs 8") {
t.Error("the unplaced model has no explanation")
}
if strings.Count(body, "Why nothing was placed") != 1 {
t.Error("the explanation section is missing or duplicated")
}
}
func TestDashboardSurvivesAFailingUsageQuery(t *testing.T) {
// A reporting query failing must not take the page with it: the cluster
// state is what someone is most likely here for.
h := Dashboard(fakeSource{}, fakeUsage{err: context.DeadlineExceeded}, "v1", time.Second, nil)
code, body := get(t, h, "http://127.0.0.1:9090/", nil)
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "llmgw-node01") {
t.Error("the cluster state was lost with the usage query")
}
}
func TestDashboardSaysWhenThereIsNothingToAccount(t *testing.T) {
h := Dashboard(fakeSource{}, nil, "v1", time.Second, nil)
_, body := get(t, h, "http://127.0.0.1:9090/", nil)
if !strings.Contains(body, "Authentication is off") {
t.Error("the page does not explain the empty usage section")
}
}
func TestDashboardRendersAnEmptyCluster(t *testing.T) {
// The state a fresh install is in, and the one most likely to be looked at
// with a template that has never seen it.
h := Dashboard(emptySource{}, nil, "v1", time.Second, nil)
code, body := get(t, h, "http://127.0.0.1:9090/", nil)
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "No agents have registered") {
t.Error("an empty cluster renders nothing useful")
}
}
type emptySource struct{}
func (emptySource) Nodes() []adminapi.NodeView { return nil }
func (emptySource) Models() []adminapi.ModelView { return nil }
func (emptySource) Replicas(string) []adminapi.ReplicaView { return nil }
func (emptySource) Explain(string) adminapi.ExplainView { return adminapi.ExplainView{} }
func TestStaticIsNotAFileServer(t *testing.T) {
// A file server over the embedded templates would also serve the templates,
// which is harmless today and the kind of thing that stops being harmless.
h := Static()
if code, _ := get(t, h, "http://x/_static/base.css", nil); code != http.StatusOK {
t.Errorf("the stylesheet is not served: %d", code)
}
for _, path := range []string{
"/_static/dashboard.html", "/_static/", "/_static/../web.go",
} {
if code, _ := get(t, h, "http://x"+path, nil); code == http.StatusOK {
t.Errorf("%s was served", path)
}
}
}
func TestTheLandingPageSaysWhereToGetAKey(t *testing.T) {
// A page that explains the API and leaves the reader with no route to using
// it is where onboarding stops.
_, body := get(t, Landing("v1", true, false, "platform@example.com", ""), "http://x/", nil)
if !strings.Contains(body, `href="mailto:platform@example.com"`) {
t.Error("an address was not turned into a mailto link")
}
// With signup closed, the route is a person; the page still has to say what
// arriving looks like.
if !strings.Contains(body, "shown once") {
t.Error("the page does not say a key cannot be recovered")
}
}
func TestContactRenderingByKind(t *testing.T) {
cases := []struct{ in, text, href string }{
{"platform@example.com", "platform@example.com", "mailto:platform@example.com"},
{"mailto:ops@example.com", "mailto:ops@example.com", "mailto:ops@example.com"},
{"https://wiki.example/llmgw", "https://wiki.example/llmgw", "https://wiki.example/llmgw"},
{"http://wiki.example", "http://wiki.example", "http://wiki.example"},
// Not addresses and not URLs: shown, not linked.
{"#platform on Slack", "#platform on Slack", ""},
{"Ayse", "Ayse", ""},
{"", "", ""},
{" ", "", ""},
}
for _, c := range cases {
text, href := contactLink(c.in)
if text != c.text || href != c.href {
t.Errorf("contactLink(%q) = (%q, %q), want (%q, %q)", c.in, text, href, c.text, c.href)
}
}
}
func TestADangerousContactSchemeIsNeverLinked(t *testing.T) {
// This string comes from configuration, and configuration is not always
// written by whoever runs the gateway. A javascript: href on a page served
// to every visitor is a stored XSS with a very short path to it; escaping
// the text does not make the scheme safe.
for _, bad := range []string{
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
} {
text, href := contactLink(bad)
if href != "" {
t.Errorf("contactLink(%q) produced href %q", bad, href)
}
if text == "" {
t.Errorf("contactLink(%q) dropped the text entirely", bad)
}
_, body := get(t, Landing("v1", true, false, bad, ""), "http://x/", nil)
if strings.Contains(body, "href=\"javascript") || strings.Contains(body, "href=\"data:") {
t.Errorf("%q reached the page as a link", bad)
}
}
}
func TestTheNoticeIsEscaped(t *testing.T) {
_, body := get(t, Landing("v1", true, false, "", "<script>alert(1)</script>"), "http://x/", nil)
if strings.Contains(body, "<script>alert(1)</script>") {
t.Error("the notice was rendered as markup")
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Error("the notice was dropped rather than escaped")
}
}
func TestWithoutAContactThePageStillSaysSomethingUseful(t *testing.T) {
_, body := get(t, Landing("v1", true, false, "", ""), "http://x/", nil)
if !strings.Contains(body, "whoever operates this gateway") {
t.Error("an unconfigured gateway leaves the reader with nothing")
}
}
func TestWithSignupOpenTheLandingPagePointsAtIt(t *testing.T) {
// The route from "I found this address" to "I have a key" has to be on the
// page, or the reader is stuck whatever else it says.
_, body := get(t, Landing("v1", true, true, "", ""), "http://x/", nil)
for _, want := range []string{`href="/signup"`, `href="/login"`, `href="/profile"`} {
if !strings.Contains(body, want) {
t.Errorf("the page does not link to %s", want)
}
}
}