mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
422 lines
13 KiB
Go
422 lines
13 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ---- isValidSettingsJSON (pure validator) ----
|
|
|
|
func TestIsValidSettingsJSON(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
valid bool
|
|
}{
|
|
{"object", `{"k":"v"}`, true},
|
|
{"empty_object", `{}`, true},
|
|
{"nested", `{"a":{"b":1}}`, true},
|
|
{"null", `null`, true},
|
|
{"array", `[1,2]`, false},
|
|
{"string", `"s"`, false},
|
|
{"number", `42`, false},
|
|
{"bool", `true`, false},
|
|
{"malformed", `{`, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := isValidSettingsJSON(json.RawMessage(c.raw))
|
|
if got != c.valid {
|
|
t.Errorf("isValidSettingsJSON(%s) = %v, want %v", c.raw, got, c.valid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- setTenantConfigRequest JSON decode (pointer semantics) ----
|
|
|
|
func TestSetTenantConfigRequest_DecodeSemantics(t *testing.T) {
|
|
// Enabled present and false — pointer must be non-nil with *Enabled == false.
|
|
var req1 setTenantConfigRequest
|
|
if err := json.Unmarshal([]byte(`{"enabled":false}`), &req1); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req1.Enabled == nil || *req1.Enabled != false {
|
|
t.Errorf("expected enabled=false, got %v", req1.Enabled)
|
|
}
|
|
if req1.Settings != nil {
|
|
t.Errorf("expected settings=nil, got %s", req1.Settings)
|
|
}
|
|
|
|
// Settings only.
|
|
var req2 setTenantConfigRequest
|
|
if err := json.Unmarshal([]byte(`{"settings":{"brave":{"max_results":20}}}`), &req2); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req2.Enabled != nil {
|
|
t.Errorf("expected enabled=nil, got %v", *req2.Enabled)
|
|
}
|
|
if req2.Settings == nil {
|
|
t.Errorf("expected settings non-nil")
|
|
}
|
|
|
|
// Empty body — both nil.
|
|
var req3 setTenantConfigRequest
|
|
if err := json.Unmarshal([]byte(`{}`), &req3); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req3.Enabled != nil || req3.Settings != nil {
|
|
t.Errorf("empty body should produce both nil, got enabled=%v settings=%s", req3.Enabled, req3.Settings)
|
|
}
|
|
|
|
// Settings null literal — RawMessage preserves "null".
|
|
var req4 setTenantConfigRequest
|
|
if err := json.Unmarshal([]byte(`{"settings":null}`), &req4); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
// json.RawMessage omitempty: RawMessage(nil) is omit; RawMessage{"null"} is present.
|
|
// Go's json package omits null-valued fields with omitempty — req4.Settings will be nil.
|
|
// This is a known edge case: distinguishing "not provided" from "explicit null" isn't
|
|
// possible without a custom unmarshal. The handler treats both as "don't write settings".
|
|
// We document the behavior here.
|
|
_ = req4
|
|
}
|
|
|
|
// ---- Stub store + tenant store for handler tests ----
|
|
|
|
type stubTenantCfgStore struct {
|
|
mu sync.Mutex
|
|
enabled map[string]bool // toolName → enabled
|
|
settings map[string]json.RawMessage // toolName → settings bytes
|
|
}
|
|
|
|
func newStubTenantCfgStore() *stubTenantCfgStore {
|
|
return &stubTenantCfgStore{
|
|
enabled: make(map[string]bool),
|
|
settings: make(map[string]json.RawMessage),
|
|
}
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) ListDisabled(_ context.Context, tid uuid.UUID) ([]string, error) {
|
|
if tid == uuid.Nil {
|
|
return nil, store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []string
|
|
for k, v := range s.enabled {
|
|
if !v {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) ListAll(_ context.Context, tid uuid.UUID) (map[string]bool, error) {
|
|
if tid == uuid.Nil {
|
|
return nil, store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make(map[string]bool, len(s.enabled))
|
|
maps.Copy(out, s.enabled)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) Set(_ context.Context, tid uuid.UUID, name string, enabled bool) error {
|
|
if tid == uuid.Nil {
|
|
return store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
s.enabled[name] = enabled
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) Delete(_ context.Context, tid uuid.UUID, name string) error {
|
|
if tid == uuid.Nil {
|
|
return store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
delete(s.enabled, name)
|
|
delete(s.settings, name)
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) GetSettings(_ context.Context, tid uuid.UUID, name string) (json.RawMessage, error) {
|
|
if tid == uuid.Nil {
|
|
return nil, store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.settings[name], nil
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) SetSettings(_ context.Context, tid uuid.UUID, name string, raw json.RawMessage) error {
|
|
if tid == uuid.Nil {
|
|
return store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
if raw == nil {
|
|
delete(s.settings, name)
|
|
} else {
|
|
s.settings[name] = append(json.RawMessage(nil), raw...)
|
|
}
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *stubTenantCfgStore) ListAllSettings(_ context.Context, tid uuid.UUID) (map[string]json.RawMessage, error) {
|
|
if tid == uuid.Nil {
|
|
return nil, store.ErrInvalidTenant
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make(map[string]json.RawMessage, len(s.settings))
|
|
for k, v := range s.settings {
|
|
out[k] = append(json.RawMessage(nil), v...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- Handler harness ----
|
|
|
|
// buildTenantCfgHandler returns a handler wired with a stub store and a
|
|
// valid-tenant ctx injected via a wrapping http.HandlerFunc. The wrapper
|
|
// sets tenant + owner role so requireTenantAdmin short-circuits (system
|
|
// owner bypass) and the inner handler sees a non-nil tid.
|
|
func buildTenantCfgHandler(tcfg *stubTenantCfgStore, tid uuid.UUID) (*BuiltinToolsHandler, http.HandlerFunc) {
|
|
h := &BuiltinToolsHandler{tenantCfgStore: tcfg}
|
|
inject := func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := store.WithTenantID(r.Context(), tid)
|
|
ctx = store.WithRole(ctx, store.RoleOwner) // bypass tenant membership check
|
|
r = r.WithContext(ctx)
|
|
// Route to the target handler based on method.
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
h.handleSetTenantConfig(w, r)
|
|
case http.MethodGet:
|
|
h.handleGetTenantConfig(w, r)
|
|
}
|
|
}
|
|
return h, inject
|
|
}
|
|
|
|
// mustDoPut wires a mux pattern so r.PathValue("name") resolves correctly.
|
|
func mustDo(t *testing.T, inject http.HandlerFunc, method, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return mustDoTool(t, inject, method, "web_search", body)
|
|
}
|
|
|
|
func mustDoTool(t *testing.T, inject http.HandlerFunc, method, toolName, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(method+" /v1/tools/builtin/{name}/tenant-config", inject)
|
|
req := httptest.NewRequest(method, "/v1/tools/builtin/"+toolName+"/tenant-config", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// ---- PUT handler tests ----
|
|
|
|
func TestPutTenantConfig_EnabledOnly_PreservesSettings(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
tid := uuid.New()
|
|
// Pre-seed settings.
|
|
_ = tcfg.SetSettings(context.Background(), tid, "web_search", json.RawMessage(`{"k":"before"}`))
|
|
|
|
_, inject := buildTenantCfgHandler(tcfg, tid)
|
|
rec := mustDo(t, inject, http.MethodPut, `{"enabled":true}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !tcfg.enabled["web_search"] {
|
|
t.Errorf("expected enabled=true persisted")
|
|
}
|
|
// Settings untouched.
|
|
if string(tcfg.settings["web_search"]) != `{"k":"before"}` {
|
|
t.Errorf("settings lost: %s", tcfg.settings["web_search"])
|
|
}
|
|
}
|
|
|
|
func TestPutTenantConfig_SettingsOnly_PreservesEnabled(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
tid := uuid.New()
|
|
_ = tcfg.Set(context.Background(), tid, "web_search", true)
|
|
|
|
_, inject := buildTenantCfgHandler(tcfg, tid)
|
|
rec := mustDo(t, inject, http.MethodPut, `{"settings":{"brave":{"max_results":20}}}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := string(tcfg.settings["web_search"]); !strings.Contains(got, "brave") {
|
|
t.Errorf("settings not persisted, got: %s", got)
|
|
}
|
|
// Enabled untouched.
|
|
if !tcfg.enabled["web_search"] {
|
|
t.Errorf("enabled flag lost")
|
|
}
|
|
}
|
|
|
|
func TestPutTenantConfig_Both(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
tid := uuid.New()
|
|
_, inject := buildTenantCfgHandler(tcfg, tid)
|
|
|
|
rec := mustDo(t, inject, http.MethodPut, `{"enabled":true,"settings":{"k":"v"}}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !tcfg.enabled["web_search"] || string(tcfg.settings["web_search"]) != `{"k":"v"}` {
|
|
t.Errorf("both fields not persisted: enabled=%v settings=%s", tcfg.enabled["web_search"], tcfg.settings["web_search"])
|
|
}
|
|
}
|
|
|
|
func TestPutTenantConfig_Neither_Returns400(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
_, inject := buildTenantCfgHandler(tcfg, uuid.New())
|
|
|
|
rec := mustDo(t, inject, http.MethodPut, `{}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("empty body status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPutTenantConfig_InvalidSettingsJSON_Returns400(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
_, inject := buildTenantCfgHandler(tcfg, uuid.New())
|
|
|
|
// Array is valid JSON but not a JSON object.
|
|
rec := mustDo(t, inject, http.MethodPut, `{"settings":[1,2,3]}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("array settings status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPutTenantConfig_InvalidExecTimeoutSettings_Returns400(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
_, inject := buildTenantCfgHandler(tcfg, uuid.New())
|
|
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"non_number", `{"settings":{"timeout_seconds":"60"}}`},
|
|
{"zero", `{"settings":{"timeout_seconds":0}}`},
|
|
{"negative", `{"settings":{"timeout_seconds":-1}}`},
|
|
{"above_max", `{"settings":{"timeout_seconds":3601}}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := mustDoTool(t, inject, http.MethodPut, "exec", tc.body)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuiltinToolsUpdate_InvalidExecTimeoutSettings_Returns400(t *testing.T) {
|
|
recStore := &recordingBuiltinToolStore{}
|
|
h := &BuiltinToolsHandler{store: recStore}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.handleUpdate)
|
|
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"non_object", `{"settings":[1,2]}`},
|
|
{"non_number", `{"settings":{"timeout_seconds":"60"}}`},
|
|
{"zero", `{"settings":{"timeout_seconds":0}}`},
|
|
{"negative", `{"settings":{"timeout_seconds":-1}}`},
|
|
{"above_max", `{"settings":{"timeout_seconds":3601}}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/tools/builtin/exec", strings.NewReader(tc.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
ctx := store.WithTenantID(req.Context(), store.MasterTenantID)
|
|
ctx = store.WithRole(ctx, "admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
rr := httptest.NewRecorder()
|
|
mux.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
if recStore.updateName != "" {
|
|
t.Fatalf("invalid settings reached store update for %q", recStore.updateName)
|
|
}
|
|
}
|
|
|
|
func TestPutTenantConfig_BackwardCompatEnabledOnly(t *testing.T) {
|
|
// Old clients send { enabled: bool } only — must still work.
|
|
tcfg := newStubTenantCfgStore()
|
|
tid := uuid.New()
|
|
_, inject := buildTenantCfgHandler(tcfg, tid)
|
|
|
|
rec := mustDo(t, inject, http.MethodPut, `{"enabled":false}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if tcfg.enabled["web_search"] {
|
|
t.Errorf("expected enabled=false persisted")
|
|
}
|
|
}
|
|
|
|
// ---- GET handler test ----
|
|
|
|
func TestGetTenantConfig_ReturnsCombinedView(t *testing.T) {
|
|
tcfg := newStubTenantCfgStore()
|
|
tid := uuid.New()
|
|
_ = tcfg.Set(context.Background(), tid, "web_search", true)
|
|
_ = tcfg.SetSettings(context.Background(), tid, "web_search", json.RawMessage(`{"k":"v"}`))
|
|
|
|
_, inject := buildTenantCfgHandler(tcfg, tid)
|
|
rec := mustDo(t, inject, http.MethodGet, "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
ToolName string `json:"tool_name"`
|
|
Enabled *bool `json:"enabled"`
|
|
Settings json.RawMessage `json:"settings"`
|
|
}
|
|
raw, _ := io.ReadAll(rec.Body)
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if resp.ToolName != "web_search" {
|
|
t.Errorf("tool_name = %s", resp.ToolName)
|
|
}
|
|
if resp.Enabled == nil || !*resp.Enabled {
|
|
t.Errorf("expected enabled=true in response")
|
|
}
|
|
if !bytes.Contains(resp.Settings, []byte(`"k":"v"`)) {
|
|
t.Errorf("settings missing from response: %s", resp.Settings)
|
|
}
|
|
}
|