Files
llmgw/test/e2e/signup_test.go
T
Ilker ManapandClaude Opus 5.5 44cd58005a Count sign-in attempts per address and client, guard the sign-in form with a token, ration both forms per address
A wrong password from anywhere counted against the address, so ten locked
its owner out. Attempts are now counted per address and client, with a
looser bound per address alone. The sign-in form carries a token matched
by a cookie (login CSRF). POST /login and /signup are rationed per client
address, a hash waits at most ten seconds for a turn, and server init
writes signup_open: false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:26:29 +02:00

630 lines
20 KiB
Go

package e2e
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// browser is a client that keeps cookies and does not follow redirects, so a
// test can see them.
type browser struct {
t *testing.T
base string
c *http.Client
}
func newBrowser(t *testing.T, base string) *browser {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &browser{t: t, base: base, c: &http.Client{
Jar: jar,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}}
}
func (b *browser) get(path string) (int, string) {
b.t.Helper()
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, b.base+path, nil)
resp, err := b.c.Do(req)
if err != nil {
b.t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
func (b *browser) post(path string, form url.Values) (int, string, string) {
b.t.Helper()
// The sign-in form carries a token its page set, as a browser's would.
if path == "/login" && form.Get("csrf") == "" {
_, page := b.get("/login")
form = cloneValues(form)
form.Set("csrf", csrf(b.t, page))
}
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
b.base+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := b.c.Do(req)
if err != nil {
b.t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body), resp.Header.Get("Location")
}
// csrf pulls the token the server put in the page, from a form field or the
// meta tag the JSON-posting pages use.
func csrf(t *testing.T, body string) string {
t.Helper()
for _, marker := range []string{`name="csrf" value="`, `name="csrf" content="`} {
if i := strings.Index(body, marker); i >= 0 {
rest := body[i+len(marker):]
return rest[:strings.IndexByte(rest, '"')]
}
}
t.Fatal("no csrf token in the page")
return ""
}
func signupHarness(t *testing.T) (*harness, string) {
t.Helper()
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{
Enabled: true, Store: path, SignupOpen: true,
CacheTTL: 50 * time.Millisecond,
}
})
return h, path
}
const password = "a long enough passphrase"
func TestAnAccountIsUselessUntilItIsApproved(t *testing.T) {
// The whole reason a public signup form is safe: it produces something that
// cannot do anything.
h, path := signupHarness(t)
b := newBrowser(t, h.router.URL)
code, body, _ := b.post("/signup", url.Values{
"email": {"ayse@example.com"}, "name": {"Ayse"}, "password": {password},
})
if code != http.StatusOK || !strings.Contains(body, "waiting for approval") {
t.Fatalf("signup returned %d: %s", code, body)
}
code, body, _ = b.post("/login", url.Values{
"email": {"ayse@example.com"}, "password": {password},
})
if code != http.StatusUnauthorized || !strings.Contains(body, "waiting for approval") {
t.Fatalf("a pending account signed in: %d", code)
}
// The profile page is not reachable either.
if code, _ := b.get("/profile"); code != http.StatusSeeOther {
t.Errorf("an unauthenticated visit to /profile returned %d", code)
}
// Approve, and it all works.
withStore(t, path, func(s *store.DB) {
users, err := s.Users(context.Background(), store.UserPending)
if err != nil || len(users) != 1 {
t.Fatalf("pending users: %v %v", users, err)
}
if err := s.ApproveUser(context.Background(), users[0].ID, "proj", "test", time.Now()); err != nil {
t.Fatal(err)
}
})
code, _, loc := b.post("/login", url.Values{
"email": {"ayse@example.com"}, "password": {password},
})
if code != http.StatusSeeOther || loc != "/profile" {
t.Fatalf("sign-in after approval returned %d to %q", code, loc)
}
}
func TestAUserIssuesTheirOwnKeyAndItWorks(t *testing.T) {
h, path := signupHarness(t)
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
_, profile := b.get("/profile")
token := csrf(t, profile)
code, body, _ := b.post("/profile/keys", url.Values{
"csrf": {token}, "name": {"laptop"},
})
if code != http.StatusOK {
t.Fatalf("creating a key returned %d", code)
}
key := extractKey(t, body)
// The key the person made for themselves is a real key.
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
t.Fatalf("a self-issued key does not work: %d %s", code, msg)
}
// And it is listed afterwards, without the secret.
_, profile = b.get("/profile")
if !strings.Contains(profile, "laptop") {
t.Error("the key is not listed")
}
if strings.Contains(profile, key) {
t.Error("the profile page shows the key again; it is meant to be shown once")
}
}
func TestAUserCannotRevokeSomeoneElsesKey(t *testing.T) {
// The ownership check is in the statement rather than a prior read, so a
// guessed id from another account changes nothing.
h, path := signupHarness(t)
ayse := newBrowser(t, h.router.URL)
signUpAndApprove(t, ayse, path, "ayse@example.com")
_, p1 := ayse.get("/profile")
_, body, _ := ayse.post("/profile/keys", url.Values{"csrf": {csrf(t, p1)}, "name": {"ayse-key"}})
ayseKey := extractKey(t, body)
var ayseKeyID string
withStore(t, path, func(s *store.DB) {
users, _ := s.Users(context.Background(), store.UserActive)
for _, u := range users {
if u.Email == "ayse@example.com" {
keys, _ := s.KeysForUser(context.Background(), u.ID)
if len(keys) != 1 {
t.Fatalf("ayse has %d keys", len(keys))
}
ayseKeyID = keys[0].ID
}
}
})
mehmet := newBrowser(t, h.router.URL)
signUpAndApprove(t, mehmet, path, "mehmet@example.com")
_, p2 := mehmet.get("/profile")
if code, _, _ := mehmet.post("/profile/keys/"+ayseKeyID+"/revoke",
url.Values{"csrf": {csrf(t, p2)}}); code != http.StatusSeeOther {
t.Fatalf("status %d", code)
}
// It must still work.
if code, msg := h.call(t, ayseKey, testModel); code != http.StatusOK {
t.Errorf("someone else revoked this key: %d %s", code, msg)
}
}
func TestAFormWithoutItsTokenIsRefused(t *testing.T) {
// A cookie authenticates the browser, not the page that asked. Without this,
// any site the person visits can post here and have the browser attach the
// session for it.
h, path := signupHarness(t)
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
for _, bad := range []url.Values{
{"name": {"x"}},
{"csrf": {""}, "name": {"x"}},
{"csrf": {"forged"}, "name": {"x"}},
} {
if code, _, _ := b.post("/profile/keys", bad); code != http.StatusBadRequest {
t.Errorf("a form with csrf=%q was accepted: %d", bad.Get("csrf"), code)
}
}
}
func TestSigningOutEndsTheSession(t *testing.T) {
h, path := signupHarness(t)
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
_, profile := b.get("/profile")
if _, _, _ = b.post("/logout", url.Values{"csrf": {csrf(t, profile)}}); false {
t.Fatal("unreachable")
}
if code, _ := b.get("/profile"); code != http.StatusSeeOther {
t.Errorf("the profile was still reachable after signing out: %d", code)
}
}
func TestSuspensionClosesOpenSessions(t *testing.T) {
h, path := signupHarness(t)
b := newBrowser(t, h.router.URL)
id := signUpAndApprove(t, b, path, "ayse@example.com")
if code, _ := b.get("/profile"); code != http.StatusOK {
t.Fatalf("profile before suspension: %d", code)
}
withStore(t, path, func(s *store.DB) {
if err := s.SetUserState(context.Background(), id, store.UserSuspended); err != nil {
t.Fatal(err)
}
})
if code, _ := b.get("/profile"); code != http.StatusSeeOther {
t.Errorf("a suspended account kept its session: %d", code)
}
}
func TestSignupCanBeClosed(t *testing.T) {
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: false}
})
b := newBrowser(t, h.router.URL)
if code, body := b.get("/signup"); code == http.StatusOK && strings.Contains(body, "<form") {
t.Errorf("the signup form is served although signup is closed: %d", code)
}
// The status the mux gives depends on which catch-all the path falls
// through to, which is not the point. What matters is that no account
// appears.
if code, _, _ := b.post("/signup", url.Values{
"email": {"a@example.com"}, "password": {password},
}); code == http.StatusOK || code == http.StatusSeeOther {
t.Errorf("signup accepted a post although it is closed: %d", code)
}
withStore(t, path, func(s *store.DB) {
users, err := s.Users(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if len(users) != 0 {
t.Errorf("%d accounts exist although signup is closed", len(users))
}
})
}
// signUpAndApprove runs the operator's half and returns the new user's id.
func signUpAndApprove(t *testing.T, b *browser, path, email string) string {
t.Helper()
if code, _, _ := b.post("/signup", url.Values{
"email": {email}, "name": {email}, "password": {password},
}); code != http.StatusOK {
t.Fatalf("signup returned %d", code)
}
var id string
withStore(t, path, func(s *store.DB) {
users, err := s.Users(context.Background(), store.UserPending)
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.Email == email {
id = u.ID
}
}
if id == "" {
t.Fatalf("no pending account for %s", email)
}
if err := s.ApproveUser(context.Background(), id, "proj", "test", time.Now()); err != nil {
t.Fatal(err)
}
})
if code, _, _ := b.post("/login", url.Values{
"email": {email}, "password": {password},
}); code != http.StatusSeeOther {
t.Fatalf("sign-in returned %d", code)
}
return id
}
func TestChangingAPasswordKeepsThisBrowserAndDropsTheOthers(t *testing.T) {
// Both halves matter. Signing every session out is the security property;
// keeping this one is what stops the person who just proved their password
// from being thrown back to the sign-in page.
h, path := signupHarness(t)
laptop := newBrowser(t, h.router.URL)
signUpAndApprove(t, laptop, path, "ayse@example.com")
// A second browser, signed in as the same person.
phone := newBrowser(t, h.router.URL)
if code, _, _ := phone.post("/login", url.Values{
"email": {"ayse@example.com"}, "password": {password},
}); code != http.StatusSeeOther {
t.Fatalf("second sign-in returned %d", code)
}
if code, _ := phone.get("/profile"); code != http.StatusOK {
t.Fatalf("the second browser was not signed in: %d", code)
}
_, profile := laptop.get("/profile")
const newPassword = "an entirely different passphrase"
code, body, _ := laptop.post("/profile/password", url.Values{
"csrf": {csrf(t, profile)}, "current": {password}, "new": {newPassword},
})
if code != http.StatusOK {
t.Fatalf("changing the password returned %d", code)
}
if !strings.Contains(body, "password was changed") {
t.Errorf("the page does not confirm the change")
}
// This browser is still signed in, and its forms still work — which they
// only do if the new CSRF token reached the page that was just rendered.
newToken := csrf(t, body)
if code, _, _ := laptop.post("/profile/keys", url.Values{
"csrf": {newToken}, "name": {"after-change"},
}); code != http.StatusOK {
t.Errorf("the form on the page after a password change was refused: %d", code)
}
// The other browser is not.
if code, _ := phone.get("/profile"); code != http.StatusSeeOther {
t.Errorf("the other browser kept its session: %d", code)
}
// And the old password is gone.
fresh := newBrowser(t, h.router.URL)
if code, _, _ := fresh.post("/login", url.Values{
"email": {"ayse@example.com"}, "password": {password},
}); code != http.StatusUnauthorized {
t.Errorf("the old password still signs in: %d", code)
}
if code, _, _ := fresh.post("/login", url.Values{
"email": {"ayse@example.com"}, "password": {newPassword},
}); code != http.StatusSeeOther {
t.Errorf("the new password does not sign in: %d", code)
}
}
func TestAWrongCurrentPasswordChangesNothing(t *testing.T) {
h, path := signupHarness(t)
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
_, profile := b.get("/profile")
_, body, _ := b.post("/profile/password", url.Values{
"csrf": {csrf(t, profile)}, "current": {"wrong"}, "new": {"something else entirely"},
})
if !strings.Contains(body, "not your current password") {
t.Error("the page does not say why it was refused")
}
// Still signed in, still on the old password.
if code, _ := b.get("/profile"); code != http.StatusOK {
t.Errorf("a failed change signed the person out: %d", code)
}
}
func TestTheClusterPageIsOnlyForAdmins(t *testing.T) {
// Serving the dashboard over the web needs an actual answer to "who may see
// this", and the answer must not be "anyone with an account".
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: true, CacheTTL: 50 * time.Millisecond}
})
h.rt.SetClusterView(fakeCluster{}, nil)
// Rebuild the handler now that there is a cluster to show.
srv := httptest.NewServer(h.rt.Handler())
defer srv.Close()
// A stranger is sent to sign in, which reveals nothing: /login is public.
anon := newBrowser(t, srv.URL)
if code, _ := anon.get("/admin"); code != http.StatusSeeOther {
t.Errorf("an anonymous visit returned %d", code)
}
// An ordinary account gets 404, not 403. 403 would confirm the page exists
// and that admin accounts are a thing here.
user := newBrowser(t, srv.URL)
id := signUpAndApproveAt(t, user, srv.URL, path, "ayse@example.com")
code, body := user.get("/admin")
if code != http.StatusNotFound {
t.Errorf("a non-admin got %d, want 404", code)
}
if strings.Contains(strings.ToLower(body), "forbidden") ||
strings.Contains(strings.ToLower(body), "admin") {
t.Error("the refusal admits the page exists")
}
// And the profile page does not advertise a door they cannot open.
if _, profile := user.get("/profile"); strings.Contains(profile, `href="/admin"`) {
t.Error("a non-admin is shown a link to the cluster")
}
withStore(t, path, func(s *store.DB) {
if err := s.SetAdmin(context.Background(), id, true); err != nil {
t.Fatal(err)
}
})
code, body = user.get("/admin")
if code != http.StatusOK {
t.Fatalf("an admin got %d", code)
}
if !strings.Contains(body, "llmgw-node01") {
t.Error("the page does not show the cluster")
}
if _, profile := user.get("/profile"); !strings.Contains(profile, `href="/admin"`) {
t.Error("an admin is not shown the link")
}
}
// signUpAndApproveAt is signUpAndApprove against an explicitly given base URL,
// for tests that rebuild the handler.
func signUpAndApproveAt(t *testing.T, b *browser, base, path, email string) string {
t.Helper()
_ = base
return signUpAndApprove(t, b, path, email)
}
// fakeCluster is enough of a cluster for the page to render.
type fakeCluster struct{}
func (fakeCluster) Nodes() []adminapi.NodeView {
return []adminapi.NodeView{{ID: "llmgw-node01", State: "ready", GPUTotal: 8, GPUFree: 8}}
}
func (fakeCluster) Models() []adminapi.ModelView { return nil }
func (fakeCluster) Replicas(string) []adminapi.ReplicaView { return nil }
func (fakeCluster) Summary() adminapi.Summary { return adminapi.Summary{} }
func (fakeCluster) Explain(string) adminapi.ExplainView { return adminapi.ExplainView{} }
func TestTheChatPageTalksToTheModelWithoutTheBrowserHoldingAKey(t *testing.T) {
// The point of the page: someone tries a model without setting up a client,
// and the browser never sees a credential. The request still goes down the
// ordinary path, so every limit that applies to an API key applies here.
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: true, CacheTTL: 50 * time.Millisecond}
})
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
code, page := b.get("/chat")
if code != http.StatusOK {
t.Fatalf("/chat returned %d", code)
}
if !strings.Contains(page, testModel) {
t.Errorf("the model picker does not offer %s", testModel)
}
// Whatever else is on the page, a key is not.
if strings.Contains(page, store.KeyPrefix) {
t.Error("an API key was rendered into the chat page")
}
token := csrf(t, page)
body, _ := json.Marshal(map[string]any{
"model": testModel,
"messages": []map[string]string{{"role": "user", "content": "hello"}},
})
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
h.router.URL+"/chat/send", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", token)
resp, err := b.c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
out, _ := io.ReadAll(resp.Body)
t.Fatalf("chat send returned %d: %s", resp.StatusCode, out)
}
out, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(out), "data:") {
t.Errorf("the reply did not stream: %.200s", out)
}
}
func TestTheChatPageIsNotOpenToStrangers(t *testing.T) {
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: true}
})
anon := newBrowser(t, h.router.URL)
if code, _ := anon.get("/chat"); code != http.StatusSeeOther {
t.Errorf("an anonymous visit to /chat returned %d", code)
}
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
h.router.URL+"/chat/send", strings.NewReader(`{"model":"m","messages":[{}]}`))
req.Header.Set("Content-Type", "application/json")
resp, err := anon.c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("an anonymous send returned %d", resp.StatusCode)
}
}
func TestChatObeysTheModelAllowlist(t *testing.T) {
// If the page could reach a model the key path refuses, it would be a hole
// in the limits everything else enforces.
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: true, CacheTTL: 50 * time.Millisecond}
})
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
withStore(t, path, func(s *store.DB) {
if err := s.PutLimits(context.Background(), store.Limits{
Scope: store.ScopeProject, ScopeID: "proj",
AllowedModels: []string{"something-else"},
}); err != nil {
t.Fatal(err)
}
})
_, page := b.get("/chat")
if strings.Contains(page, `value="`+testModel+`"`) {
t.Error("the picker offers a model the allowlist forbids")
}
body, _ := json.Marshal(map[string]any{
"model": testModel, "messages": []map[string]string{{"role": "user", "content": "hi"}},
})
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
h.router.URL+"/chat/send", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrf(t, page))
resp, err := b.c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("asking for a forbidden model returned %d", resp.StatusCode)
}
}
func TestChatSendNeedsItsToken(t *testing.T) {
// A JSON post carries the token in a header, which a cross-site form cannot
// set at all.
path, _ := provision(t)
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: true}
})
b := newBrowser(t, h.router.URL)
signUpAndApprove(t, b, path, "ayse@example.com")
for _, token := range []string{"", "forged"} {
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
h.router.URL+"/chat/send", strings.NewReader(`{"model":"m","messages":[{}]}`))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := b.c.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a send with token %q returned %d", token, resp.StatusCode)
}
}
}
func cloneValues(v url.Values) url.Values {
out := url.Values{}
for k, vs := range v {
out[k] = append([]string{}, vs...)
}
return out
}