package server import ( "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/crypto/byteclaw/internal/agent" "github.com/crypto/byteclaw/internal/store" "github.com/crypto/byteclaw/internal/tools" ) func authSetup(t *testing.T, allowReg bool) (*httptest.Server, *store.Store, *Server) { st, err := store.Open(filepath.Join(t.TempDir(), "s.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) s := &Server{Store: st, Token: tok, AllowRegistration: allowReg, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}} ts := httptest.NewServer(s.Handler()) t.Cleanup(ts.Close) return ts, st, s } type authResp struct { Token string `json:"token"` User struct { ID int64 `json:"id"` Username string `json:"username"` } `json:"user"` } func creds(u, p string) string { b, _ := json.Marshal(map[string]string{"username": u, "password": p}) return string(b) } func registerUser(t *testing.T, base, u, p string) string { t.Helper() r := do(t, "POST", base+"/api/auth/register", creds(u, p), nil) if r.StatusCode != 201 { b, _ := io.ReadAll(r.Body) t.Fatalf("register %s: %d %s", u, r.StatusCode, b) } var a authResp json.NewDecoder(r.Body).Decode(&a) if a.Token == "" || a.User.Username != u { t.Fatalf("bad register response %+v", a) } return a.Token } func bearerHdr(t string) map[string]string { return map[string]string{"Authorization": "Bearer " + t} } func TestRegisterLoginMeLogout(t *testing.T) { ts, _, _ := authSetup(t, true) t1 := registerUser(t, ts.URL, "alice", "supersecret") if r := do(t, "POST", ts.URL+"/api/auth/register", creds("ALICE", "supersecret"), nil); r.StatusCode != 409 { t.Fatalf("duplicate (case-insensitive) must be 409, got %d", r.StatusCode) } r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)) var me map[string]any json.NewDecoder(r.Body).Decode(&me) if r.StatusCode != 200 || me["username"] != "alice" { t.Fatalf("me: %d %v", r.StatusCode, me) } if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrongpass"), nil); r.StatusCode != 401 { t.Fatalf("wrong password: %d", r.StatusCode) } if r := do(t, "POST", ts.URL+"/api/auth/login", creds("nobody", "whatever1"), nil); r.StatusCode != 401 { t.Fatalf("unknown user: %d", r.StatusCode) } r = do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil) var a authResp json.NewDecoder(r.Body).Decode(&a) if r.StatusCode != 200 || a.Token == "" || a.Token == t1 { t.Fatalf("login: %d", r.StatusCode) } if r := do(t, "POST", ts.URL+"/api/auth/logout", "", bearerHdr(a.Token)); r.StatusCode != 204 { t.Fatalf("logout: %d", r.StatusCode) } if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(a.Token)); r.StatusCode != 401 { t.Fatalf("revoked token must be 401, got %d", r.StatusCode) } if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)); r.StatusCode != 200 { t.Fatalf("other token must stay valid, got %d", r.StatusCode) } } func TestRegisterValidation(t *testing.T) { ts, _, _ := authSetup(t, true) for _, c := range []struct{ u, p string }{{"ab", "longenough"}, {strings.Repeat("a", 33), "longenough"}, {"bad name", "longenough"}, {"bob", "short"}} { if r := do(t, "POST", ts.URL+"/api/auth/register", creds(c.u, c.p), nil); r.StatusCode != 400 { t.Fatalf("%q/%q: want 400 got %d", c.u, c.p, r.StatusCode) } } } func TestRegistrationDisabled(t *testing.T) { ts, _, _ := authSetup(t, false) if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "supersecret"), nil); r.StatusCode != 403 { t.Fatalf("want 403 got %d", r.StatusCode) } } func TestAuthRequired(t *testing.T) { ts, _, _ := authSetup(t, true) for _, p := range []string{"/api/sessions", "/api/info", "/api/auth/me", "/api/providers", "/api/ws"} { if r := do(t, "GET", ts.URL+p, "", nil); r.StatusCode != 401 { t.Fatalf("%s without token: %d", p, r.StatusCode) } if r := do(t, "GET", ts.URL+p, "", bearerHdr("bc_invalid")); r.StatusCode != 401 { t.Fatalf("%s bad token: %d", p, r.StatusCode) } } if r := do(t, "GET", ts.URL+"/api/sessions?token="+tok, "", nil); r.StatusCode != 401 { t.Fatalf("query token must only work for ws, got %d", r.StatusCode) } // static admin token still works if r := do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tok)); r.StatusCode != 200 { t.Fatalf("static token: %d", r.StatusCode) } } func TestPerUserIsolation(t *testing.T) { ts, st, _ := authSetup(t, true) legacy, _ := st.CreateSession(t.Context(), "legacy") ta := registerUser(t, ts.URL, "alice", "supersecret") tb := registerUser(t, ts.URL, "bob_2", "supersecret") r := do(t, "POST", ts.URL+"/api/sessions", `{"title":"bob's"}`, bearerHdr(tb)) var bs store.Session json.NewDecoder(r.Body).Decode(&bs) list := func(tk string) []store.Session { var l []store.Session json.NewDecoder(do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tk)).Body).Decode(&l) return l } if l := list(ta); len(l) != 1 || l[0].ID != legacy.ID { t.Fatalf("first user must adopt legacy session, got %+v", l) } if l := list(tb); len(l) != 1 || l[0].ID != bs.ID { t.Fatalf("bob sees %+v", l) } for _, m := range []struct{ method, path string }{{"GET", ""}, {"GET", "/messages"}, {"DELETE", ""}, {"POST", "/cancel"}, {"GET", "/summary"}} { if r := do(t, m.method, ts.URL+"/api/sessions/"+bs.ID+m.path, "", bearerHdr(ta)); r.StatusCode != 404 { t.Fatalf("alice %s %s on bob's session: %d", m.method, m.path, r.StatusCode) } } if r := do(t, "PATCH", ts.URL+"/api/sessions/"+bs.ID, `{"title":"x"}`, bearerHdr(ta)); r.StatusCode != 404 { t.Fatalf("rename foreign: %d", r.StatusCode) } if r := do(t, "POST", ts.URL+"/api/chat", `{"session_id":"`+bs.ID+`","text":"hi"}`, bearerHdr(ta)); r.StatusCode != 404 { t.Fatalf("chat into foreign session: %d", r.StatusCode) } if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, bearerHdr(ta)); r.StatusCode != 200 { t.Fatalf("own chat: %d", r.StatusCode) } if l := list(tb); len(l) != 1 { t.Fatalf("alice's new chat leaked to bob: %+v", l) } if l := list(tok); len(l) != 3 { t.Fatalf("admin must see all sessions, got %d", len(l)) } } func TestLoginRateLimit(t *testing.T) { ts, _, s := authSetup(t, true) registerUser(t, ts.URL, "alice", "supersecret") s.limiter.Reset("127.0.0.1") for i := 0; i < loginMaxFails; i++ { if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrong-pass"), nil); r.StatusCode != 401 { t.Fatalf("attempt %d: %d", i, r.StatusCode) } } if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 429 { t.Fatalf("want 429 after %d failures, got %d", loginMaxFails, r.StatusCode) } // window expiry unblocks s.limiter.now = func() time.Time { return time.Now().Add(loginWindow + time.Second) } if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 200 { t.Fatalf("after window: %d", r.StatusCode) } } func TestWSQueryToken(t *testing.T) { ts, _, _ := authSetup(t, true) tk := registerUser(t, ts.URL, "alice", "supersecret") r := do(t, "GET", ts.URL+"/api/ws?token="+tk, "", map[string]string{"Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="}) if r.StatusCode != http.StatusSwitchingProtocols { t.Fatalf("ws with query token: %d", r.StatusCode) } }