Files
kandev/apps/backend/internal/agent/settings/controller/profile_crud_test.go
T
2026-07-10 16:09:21 +01:00

253 lines
9.1 KiB
Go

package controller
import (
"context"
"fmt"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
)
// TestSeedCLIFlags_FromCopilot verifies that a fresh Copilot profile gets
// the four curated CLI-flag suggestions seeded with the expected default
// state. `--allow-all-tools` is on by default so autonomous runs don't
// stall on per-tool-call permission prompts; the other --allow-all-* and
// --no-ask-user flags stay off as safe defaults until users opt in.
func TestSeedCLIFlags_FromCopilot(t *testing.T) {
ag := agents.NewCopilotACP()
flags := seedCLIFlags(ag)
wantFlags := map[string]bool{
"--allow-all-tools": true,
"--allow-all-paths": false,
"--allow-all-urls": false,
"--no-ask-user": false,
}
if len(flags) != len(wantFlags) {
t.Fatalf("expected %d seeded flags, got %d: %+v", len(wantFlags), len(flags), flags)
}
for _, f := range flags {
want, ok := wantFlags[f.Flag]
if !ok {
t.Errorf("unexpected seeded flag: %q", f.Flag)
continue
}
if f.Enabled != want {
t.Errorf("%q: Enabled=%v, want %v", f.Flag, f.Enabled, want)
}
if f.Description == "" {
t.Errorf("%q: missing Description", f.Flag)
}
}
// Stable ordering — flags must sort lexicographically so the UI shows
// them in the same order every time.
for i := 1; i < len(flags); i++ {
if flags[i-1].Flag >= flags[i].Flag {
t.Errorf("flags not sorted: %q >= %q", flags[i-1].Flag, flags[i].Flag)
}
}
}
// TestSeedCLIFlags_EmptyForAgentWithNoCurated handles the common case:
// most ACP agents advertise no curated flags so new profiles get an empty
// list and the user can still add custom flags.
func TestSeedCLIFlags_EmptyForAgentWithNoCurated(t *testing.T) {
ag := agents.NewClaudeACP()
flags := seedCLIFlags(ag)
if len(flags) != 0 {
t.Errorf("expected no curated flags for claude-acp, got %+v", flags)
}
}
// TestSeedCLIFlags_EmptyForCodexACP ensures the Agent Client Protocol bridge
// does not expose stale @openai/codex -c overrides as ACP subprocess flags.
func TestSeedCLIFlags_EmptyForCodexACP(t *testing.T) {
flags := seedCLIFlags(agents.NewCodexACP())
if len(flags) != 0 {
t.Fatalf("expected no seeded flags for codex-acp, got %+v", flags)
}
}
func TestSeedCLIFlags_FromCursorACP(t *testing.T) {
flags := seedCLIFlags(agents.NewCursorACP())
want := map[string]bool{"--force": false}
if len(flags) != len(want) {
t.Fatalf("expected %d seeded flags, got %d: %+v", len(want), len(flags), flags)
}
for _, f := range flags {
enabled, ok := want[f.Flag]
if !ok {
t.Errorf("unexpected seeded flag: %q", f.Flag)
continue
}
if f.Enabled != enabled {
t.Errorf("%q: Enabled=%v, want %v", f.Flag, f.Enabled, enabled)
}
}
}
// TestValidateCLIFlagDTOs rejects entries whose flag text is empty or
// whitespace only. Empty descriptions are allowed (custom flags often
// don't have one).
func TestValidateCLIFlagDTOs(t *testing.T) {
cases := []struct {
name string
flags []dto.CLIFlagDTO
wantErr bool
}{
{name: "valid", flags: []dto.CLIFlagDTO{{Flag: "--ok", Enabled: true}}},
{name: "valid with empty description", flags: []dto.CLIFlagDTO{{Flag: "--x"}}},
{name: "empty flag rejected", flags: []dto.CLIFlagDTO{{Flag: ""}}, wantErr: true},
{name: "whitespace flag rejected", flags: []dto.CLIFlagDTO{{Flag: " "}}, wantErr: true},
{name: "unterminated quote rejected", flags: []dto.CLIFlagDTO{{Flag: `--msg "hi`}}, wantErr: true},
{name: "trailing backslash rejected", flags: []dto.CLIFlagDTO{{Flag: `--path foo\`}}, wantErr: true},
{name: "double-quoted empty flag rejected", flags: []dto.CLIFlagDTO{{Flag: `""`}}, wantErr: true},
{name: "single-quoted empty flag rejected", flags: []dto.CLIFlagDTO{{Flag: `''`}}, wantErr: true},
{name: "flag with empty quoted value accepted", flags: []dto.CLIFlagDTO{{Flag: `--empty ""`}}},
{name: "empty list accepted", flags: []dto.CLIFlagDTO{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateCLIFlagDTOs(tc.flags)
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestValidateProfileEnvVarDTOs(t *testing.T) {
cases := []struct {
name string
envVars []dto.ProfileEnvVarDTO
wantErr bool
}{
{name: "valid value", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar"}}},
{name: "valid secret", envVars: []dto.ProfileEnvVarDTO{{Key: "TOKEN", SecretID: "sec-1"}}},
{name: "empty key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: ""}}, wantErr: true},
{name: "whitespace key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: " "}}, wantErr: true},
{name: "equals key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD=KEY", Value: "x"}}, wantErr: true},
{name: "null key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD\x00KEY", Value: "x"}}, wantErr: true},
{name: "duplicate key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "one"}, {Key: " FOO ", Value: "two"}}, wantErr: true},
{name: "value and secret rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar", SecretID: "sec-1"}}, wantErr: true},
{name: "null value rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bad\x00val"}}, wantErr: true},
{name: "KANDEV prefix rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "KANDEV_TASK_ID", Value: "x"}}, wantErr: true},
{name: "TASK_DESCRIPTION rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "TASK_DESCRIPTION", Value: "x"}}, wantErr: true},
{name: "too many entries rejected", envVars: func() []dto.ProfileEnvVarDTO {
out := make([]dto.ProfileEnvVarDTO, maxProfileEnvVars+1)
for i := range out {
out[i] = dto.ProfileEnvVarDTO{Key: fmt.Sprintf("K%d", i), Value: "v"}
}
return out
}(), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateProfileEnvVarDTOs(tc.envVars)
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestCreateProfile_PersistsEnvVars(t *testing.T) {
ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{
id: "test-agent",
name: "test-agent",
displayName: "Test Agent",
enabled: true,
}})
st := newFakeStore()
agent := &models.Agent{ID: "agent-1", Name: "test-agent"}
st.agents[agent.ID] = agent
st.byName[agent.Name] = agent
ctrl.repo = st
profile, err := ctrl.CreateProfile(context.Background(), CreateProfileRequest{
AgentID: "agent-1",
Name: "With env",
EnvVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar"}},
})
if err != nil {
t.Fatalf("CreateProfile: %v", err)
}
if len(profile.EnvVars) != 1 || profile.EnvVars[0].Key != "FOO" || profile.EnvVars[0].Value != "bar" {
t.Fatalf("response env vars: %+v", profile.EnvVars)
}
if len(st.created) != 1 || len(st.created[0].EnvVars) != 1 || st.created[0].EnvVars[0].Key != "FOO" {
t.Fatalf("stored env vars: %+v", st.created)
}
}
func TestCreateAndUpdateProfile_PersistsConfigOptions(t *testing.T) {
ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{
id: "test-agent",
name: "test-agent",
displayName: "Test Agent",
enabled: true,
}})
st := newFakeStore()
agent := &models.Agent{ID: "agent-1", Name: "test-agent"}
st.agents[agent.ID] = agent
st.byName[agent.Name] = agent
ctrl.repo = st
profile, err := ctrl.CreateProfile(context.Background(), CreateProfileRequest{
AgentID: "agent-1",
Name: "With config",
ConfigOptions: map[string]string{
"effort": " high ",
"model": "ignored",
"mode": "ignored",
},
})
if err != nil {
t.Fatalf("CreateProfile: %v", err)
}
if profile.ConfigOptions["effort"] != "high" || len(profile.ConfigOptions) != 1 {
t.Fatalf("response config options: %+v", profile.ConfigOptions)
}
if len(st.created) != 1 || st.created[0].ConfigOptions["effort"] != "high" || len(st.created[0].ConfigOptions) != 1 {
t.Fatalf("stored config options: %+v", st.created)
}
next := map[string]string{"effort": "low"}
updated, err := ctrl.UpdateProfile(context.Background(), UpdateProfileRequest{
ID: profile.ID,
ConfigOptions: &next,
})
if err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if updated.ConfigOptions["effort"] != "low" || len(updated.ConfigOptions) != 1 {
t.Fatalf("updated response config options: %+v", updated.ConfigOptions)
}
}
func TestCreateAgentProfiles_PersistsEnvVars(t *testing.T) {
ctrl := newTestController(nil)
st := newFakeStore()
ctrl.repo = st
profiles, err := ctrl.createAgentProfiles(context.Background(), "agent-1", "Test Agent", []CreateAgentProfileRequest{{
Name: "With env",
Model: "model-1",
EnvVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar"}},
}}, &testAgent{id: "test-agent", name: "test-agent"})
if err != nil {
t.Fatalf("createAgentProfiles: %v", err)
}
if len(profiles) != 1 || len(profiles[0].EnvVars) != 1 || profiles[0].EnvVars[0].Key != "FOO" {
t.Fatalf("created env vars: %+v", profiles)
}
}