Files
goclaw/internal/http/secure_cli_agent_grants_test.go

274 lines
8.5 KiB
Go

package http
import (
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type fakeSecureCLIGrantStore struct {
binaries map[uuid.UUID]bool
agents map[uuid.UUID]bool
grants map[uuid.UUID]*store.SecureCLIAgentGrant
createCalled bool
updateCalled bool
deleteCalled bool
}
func (s *fakeSecureCLIGrantStore) BinaryExists(_ context.Context, id uuid.UUID) (bool, error) {
return s.binaries[id], nil
}
func (s *fakeSecureCLIGrantStore) AgentExists(_ context.Context, id uuid.UUID) (bool, error) {
return s.agents[id], nil
}
func (s *fakeSecureCLIGrantStore) Create(_ context.Context, g *store.SecureCLIAgentGrant) error {
s.createCalled = true
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
s.grants[g.ID] = g
return nil
}
func (s *fakeSecureCLIGrantStore) Get(_ context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) {
if g := s.grants[id]; g != nil {
cp := *g
return &cp, nil
}
return nil, sql.ErrNoRows
}
func (s *fakeSecureCLIGrantStore) Update(context.Context, uuid.UUID, map[string]any) error {
s.updateCalled = true
return nil
}
func (s *fakeSecureCLIGrantStore) Delete(context.Context, uuid.UUID) error {
s.deleteCalled = true
return nil
}
func (s *fakeSecureCLIGrantStore) ListByBinary(_ context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
grants := make([]store.SecureCLIAgentGrant, 0, len(s.grants))
for _, grant := range s.grants {
if grant == nil || grant.BinaryID != binaryID {
continue
}
cp := *grant
grants = append(grants, cp)
}
return grants, nil
}
func (s *fakeSecureCLIGrantStore) ListByAgent(context.Context, uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
return nil, nil
}
func (s *fakeSecureCLIGrantStore) UpdateGrantEnv(context.Context, uuid.UUID, []byte) error {
s.updateCalled = true
return nil
}
func requestWithGrantPath(method string, body io.Reader, binaryID, grantID uuid.UUID) (*httptest.ResponseRecorder, *http.Request) {
req := httptest.NewRequest(method, "/v1/cli-credentials/"+binaryID.String()+"/agent-grants/"+grantID.String(), body)
req.SetPathValue("id", binaryID.String())
req.SetPathValue("grantId", grantID.String())
ctx := store.WithTenantID(req.Context(), uuid.MustParse("0193a5b0-7000-7000-8000-000000000002"))
ctx = store.WithRole(ctx, store.RoleOwner)
ctx = store.WithUserID(ctx, "admin@example.com")
return httptest.NewRecorder(), req.WithContext(ctx)
}
func requestWithBinaryPath(body io.Reader, binaryID uuid.UUID) (*httptest.ResponseRecorder, *http.Request) {
req := httptest.NewRequest(http.MethodPost, "/v1/cli-credentials/"+binaryID.String()+"/agent-grants", body)
req.SetPathValue("id", binaryID.String())
ctx := store.WithTenantID(req.Context(), uuid.MustParse("0193a5b0-7000-7000-8000-000000000002"))
ctx = store.WithRole(ctx, store.RoleOwner)
ctx = store.WithUserID(ctx, "admin@example.com")
return httptest.NewRecorder(), req.WithContext(ctx)
}
func TestSecureCLIGrantNestedRoutesRejectWrongBinaryParent(t *testing.T) {
realBinaryID := uuid.New()
pathBinaryID := uuid.New()
grantID := uuid.New()
fake := &fakeSecureCLIGrantStore{
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
grantID: {
BaseModel: store.BaseModel{ID: grantID},
BinaryID: realBinaryID,
AgentID: uuid.New(),
Enabled: true,
EncryptedEnv: []byte(`{"TOKEN":"value"}`),
},
},
}
h := NewSecureCLIGrantHandler(fake, nil, nil)
tests := []struct {
name string
method string
body string
call func(http.ResponseWriter, *http.Request)
}{
{name: "get", method: http.MethodGet, call: h.handleGet},
{name: "update", method: http.MethodPut, body: `{"enabled":false}`, call: h.handleUpdate},
{name: "delete", method: http.MethodDelete, call: h.handleDelete},
{name: "reveal", method: http.MethodPost, call: h.handleRevealEnv},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake.updateCalled = false
fake.deleteCalled = false
rr, req := requestWithGrantPath(tt.method, strings.NewReader(tt.body), pathBinaryID, grantID)
tt.call(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404 for wrong binary parent, got %d body=%s", rr.Code, rr.Body.String())
}
if fake.updateCalled {
t.Fatal("wrong-parent request must not update grant or env")
}
if fake.deleteCalled {
t.Fatal("wrong-parent request must not delete grant")
}
})
}
}
func TestSecureCLIGrantCreateValidatesBinaryAndAgentScope(t *testing.T) {
binaryID := uuid.New()
agentID := uuid.New()
tests := []struct {
name string
binaryOK bool
agentOK bool
wantStatus int
wantCreate bool
}{
{name: "missing binary", binaryOK: false, agentOK: true, wantStatus: http.StatusNotFound},
{name: "missing agent", binaryOK: true, agentOK: false, wantStatus: http.StatusNotFound},
{name: "valid scope", binaryOK: true, agentOK: true, wantStatus: http.StatusCreated, wantCreate: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &fakeSecureCLIGrantStore{
binaries: map[uuid.UUID]bool{binaryID: tt.binaryOK},
agents: map[uuid.UUID]bool{agentID: tt.agentOK},
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{},
}
h := NewSecureCLIGrantHandler(fake, nil, nil)
rr, req := requestWithBinaryPath(strings.NewReader(`{"agent_id":"`+agentID.String()+`","enabled":true}`), binaryID)
h.handleCreate(rr, req)
if rr.Code != tt.wantStatus {
t.Fatalf("expected status %d, got %d body=%s", tt.wantStatus, rr.Code, rr.Body.String())
}
if fake.createCalled != tt.wantCreate {
t.Fatalf("createCalled=%v, want %v", fake.createCalled, tt.wantCreate)
}
})
}
}
func TestValidateAndSerializeEnvVarsRejectsGoClawGatewayToken(t *testing.T) {
rr := httptest.NewRecorder()
envJSON, ok := validateAndSerializeEnvVars(rr, "en", json.RawMessage(`{
"GOCLAW_GATEWAY_TOKEN": {"kind":"sensitive","value":"test-secret-token"}
}`))
if ok || envJSON != nil {
t.Fatalf("expected GOCLAW_GATEWAY_TOKEN to be rejected by public env validator")
}
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "GOCLAW_GATEWAY_TOKEN") {
t.Fatalf("expected rejected key in response, got %s", rr.Body.String())
}
if strings.Contains(rr.Body.String(), "test-secret-token") {
t.Fatalf("secret value leaked in validation error: %s", rr.Body.String())
}
}
func TestSecureCLIGrantUpdateRejectsInvalidEnvVarsBeforeScalarUpdate(t *testing.T) {
binaryID := uuid.New()
grantID := uuid.New()
fake := &fakeSecureCLIGrantStore{
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
grantID: {
BaseModel: store.BaseModel{ID: grantID},
BinaryID: binaryID,
AgentID: uuid.New(),
Enabled: true,
},
},
}
h := NewSecureCLIGrantHandler(fake, nil, nil)
rr, req := requestWithGrantPath(http.MethodPut, strings.NewReader(`{"enabled":false,"env_vars":123}`), binaryID, grantID)
h.handleUpdate(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String())
}
if fake.updateCalled {
t.Fatal("invalid env_vars request must not persist scalar grant updates")
}
}
func TestSecureCLIGrantGetSanitizesMixedEnv(t *testing.T) {
binaryID := uuid.New()
grantID := uuid.New()
fake := &fakeSecureCLIGrantStore{
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
grantID: {
BaseModel: store.BaseModel{ID: grantID},
BinaryID: binaryID,
AgentID: uuid.New(),
Enabled: true,
EncryptedEnv: []byte(`{"TOKEN":"secret-token","PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}}`),
},
},
}
h := NewSecureCLIGrantHandler(fake, nil, nil)
rr, req := requestWithGrantPath(http.MethodGet, nil, binaryID, grantID)
h.handleGet(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
}
if strings.Contains(rr.Body.String(), "secret-token") {
t.Fatalf("sensitive grant env leaked in response: %s", rr.Body.String())
}
var got struct {
Env map[string]store.SecureCLIEnvResponseEntry `json:"env"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Env["TOKEN"].Masked || got.Env["TOKEN"].Value != nil {
t.Fatalf("TOKEN not masked: %#v", got.Env["TOKEN"])
}
if got.Env["PUBLIC_BASE_URL"].Value == nil || *got.Env["PUBLIC_BASE_URL"].Value != "https://goclaw.sh" {
t.Fatalf("PUBLIC_BASE_URL not returned: %#v", got.Env["PUBLIC_BASE_URL"])
}
}