fccfdd2ff7
CI / test (push) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
- Add mock vcsClient for unit testing helper functions in cmd/review-bot - Add 11 tests for fetchFileContext: empty files, removed file skip, content fetching, error continuation, context cancellation - Add 6 tests for fetchPatterns: empty repo, all files, specific files, invalid repo format, fetch errors, multiple repos - Add 4 tests for review/persona: LoadPersona nonexistent/non-regular/oversized, CapitalizeFirst RuneError path Coverage: cmd/review-bot 37.6% → 46.1%, review 91.5% → 92.0%
1008 lines
26 KiB
Go
1008 lines
26 KiB
Go
package review
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/go-yaml/ast"
|
|
)
|
|
|
|
func TestLoadBuiltinPersona(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
personaName string
|
|
wantErr bool
|
|
wantDisplay string
|
|
}{
|
|
{
|
|
name: "security persona",
|
|
personaName: "security",
|
|
wantErr: false,
|
|
wantDisplay: "Security Specialist",
|
|
},
|
|
{
|
|
name: "architect persona",
|
|
personaName: "architect",
|
|
wantErr: false,
|
|
wantDisplay: "Software Architect",
|
|
},
|
|
{
|
|
name: "docs persona",
|
|
personaName: "docs",
|
|
wantErr: false,
|
|
wantDisplay: "Documentation Reviewer",
|
|
},
|
|
{
|
|
name: "unknown persona",
|
|
personaName: "nonexistent",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p, err := LoadBuiltinPersona(tt.personaName)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if p.Name != tt.personaName {
|
|
t.Errorf("Name = %q, want %q", p.Name, tt.personaName)
|
|
}
|
|
if p.DisplayName != tt.wantDisplay {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, tt.wantDisplay)
|
|
}
|
|
if p.Identity == "" {
|
|
t.Error("Identity should not be empty")
|
|
}
|
|
if len(p.Focus) == 0 {
|
|
t.Error("Focus should not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonas(t *testing.T) {
|
|
names := ListBuiltinPersonas()
|
|
if len(names) == 0 {
|
|
t.Fatal("expected at least one built-in persona")
|
|
}
|
|
|
|
// Check for expected personas
|
|
expected := map[string]bool{"security": false, "architect": false, "docs": false}
|
|
for _, name := range names {
|
|
if _, ok := expected[name]; ok {
|
|
expected[name] = true
|
|
}
|
|
}
|
|
for name, found := range expected {
|
|
if !found {
|
|
t.Errorf("expected built-in persona %q not found", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromYAMLFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yaml")
|
|
|
|
content := `# Test persona
|
|
name: test
|
|
display_name: Test Persona
|
|
identity: |
|
|
You are a test persona.
|
|
Multi-line identity works.
|
|
focus:
|
|
- testing
|
|
- validation
|
|
ignore:
|
|
- nothing
|
|
severity:
|
|
major: Big problems
|
|
minor: Small problems
|
|
nit: Tiny problems
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test Persona" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
|
}
|
|
if len(p.Focus) != 2 {
|
|
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
|
}
|
|
if !strings.Contains(p.Identity, "Multi-line") {
|
|
t.Error("Identity should contain multi-line content")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromYMLFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yml")
|
|
|
|
content := `name: test
|
|
display_name: Test YML
|
|
identity: Test identity
|
|
focus:
|
|
- testing
|
|
ignore: []
|
|
severity:
|
|
major: Big
|
|
minor: Small
|
|
nit: Tiny
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test YML" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test YML")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromJSONFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
|
|
content := `{
|
|
"name": "test",
|
|
"display_name": "Test Persona",
|
|
"identity": "You are a test persona.\nMulti-line identity works.",
|
|
"focus": ["testing", "validation"],
|
|
|
|
"ignore": ["nothing"],
|
|
"severity": {
|
|
"major": "Big problems",
|
|
"minor": "Small problems",
|
|
"nit": "Tiny problems"
|
|
}
|
|
}`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test Persona" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
|
}
|
|
if len(p.Focus) != 2 {
|
|
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
|
}
|
|
if !strings.Contains(p.Identity, "Multi-line") {
|
|
t.Error("Identity should contain multi-line content")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
ext string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing name yaml",
|
|
content: "identity: test\n",
|
|
ext: ".yaml",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing identity yaml",
|
|
content: "name: test\n",
|
|
ext: ".yaml",
|
|
wantErr: "identity is required",
|
|
},
|
|
{
|
|
name: "missing name json",
|
|
content: `{"identity": "test"}`,
|
|
ext: ".json",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing identity json",
|
|
content: `{"name": "test"}`,
|
|
ext: ".json",
|
|
wantErr: "identity is required",
|
|
},
|
|
{
|
|
name: "display_name defaults to name",
|
|
content: "name: test\nidentity: test identity\n",
|
|
ext: ".yaml",
|
|
// No error expected - should succeed
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test"+tt.ext)
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Check display_name defaulting
|
|
if p.DisplayName == "" {
|
|
t.Error("DisplayName should default to Name")
|
|
}
|
|
if p.DisplayName != p.Name {
|
|
t.Errorf("DisplayName should default to Name, got %q", p.DisplayName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFileNotFound(t *testing.T) {
|
|
_, err := LoadPersona("/nonexistent/path/persona.yaml")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaInvalidYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "invalid.yaml")
|
|
if err := os.WriteFile(path, []byte("not valid yaml:\n - [broken"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for invalid YAML")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaInvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "invalid.json")
|
|
if err := os.WriteFile(path, []byte("not valid json {"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaCaseInsensitiveExtension(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ext string
|
|
}{
|
|
{"lowercase yaml", ".yaml"},
|
|
{"uppercase YAML", ".YAML"},
|
|
{"mixed case Yaml", ".Yaml"},
|
|
{"lowercase yml", ".yml"},
|
|
{"uppercase YML", ".YML"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test"+tt.ext)
|
|
content := "name: test\nidentity: test identity\n"
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed for extension %s: %v", tt.ext, err)
|
|
}
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCapitalizeFirst(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"hello", "Hello"},
|
|
{"Hello", "Hello"},
|
|
{"HELLO", "HELLO"},
|
|
{"a", "A"},
|
|
{"", ""},
|
|
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
|
|
{"über", "Über"}, // German umlaut
|
|
{"élève", "Élève"}, // French accent
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := CapitalizeFirst(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("CapitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonasReturnsEmptySlice(t *testing.T) {
|
|
// ListBuiltinPersonas should return an empty slice (not nil) on error.
|
|
// We can't easily test the error case, but we can verify the success case
|
|
// returns a proper slice.
|
|
names := ListBuiltinPersonas()
|
|
if names == nil {
|
|
t.Error("ListBuiltinPersonas should return empty slice, not nil")
|
|
}
|
|
}
|
|
|
|
func TestYAMLMultilineStrings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "multiline.yaml")
|
|
|
|
// Test literal block scalar (|) which preserves newlines
|
|
content := `name: multiline
|
|
display_name: Multiline Test
|
|
identity: |
|
|
First line.
|
|
Second line.
|
|
Third line.
|
|
focus:
|
|
- item one
|
|
ignore: []
|
|
severity:
|
|
major: Major issue
|
|
minor: Minor issue
|
|
nit: Nit
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
// Literal block scalar preserves newlines
|
|
if !strings.Contains(p.Identity, "\n") {
|
|
t.Error("Identity should contain newlines from literal block scalar")
|
|
}
|
|
if !strings.Contains(p.Identity, "Second line") {
|
|
t.Error("Identity should contain 'Second line'")
|
|
}
|
|
}
|
|
|
|
func TestYAMLComments(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "comments.yaml")
|
|
|
|
content := `# This is a comment
|
|
name: commented # inline comment
|
|
display_name: Commented Persona
|
|
# Another comment
|
|
identity: Test identity
|
|
focus:
|
|
- item # comment after item
|
|
ignore: []
|
|
severity:
|
|
major: Major
|
|
minor: Minor
|
|
nit: Nit
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
// Comments should be ignored
|
|
if p.Name != "commented" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "commented")
|
|
}
|
|
if p.Focus[0] != "item" {
|
|
t.Errorf("Focus[0] = %q, want %q", p.Focus[0], "item")
|
|
}
|
|
}
|
|
|
|
func TestYAMLDeeplyNestedRejection(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "deeply-nested.yaml")
|
|
|
|
// Build a deeply nested YAML structure that exceeds MaxYAMLDepth (20).
|
|
// Depth accumulation trace for "nested: \n level0: \n level1: ...":
|
|
// - Document root parsed at depth 0
|
|
// - Root MappingNode children (MappingValueNodes) visited at depth 1
|
|
// - "nested" MappingValueNode: key at depth 2, value at depth 2
|
|
// - Each levelN adds depth via MappingValueNode traversal (key + value)
|
|
// - Exact depth per level depends on AST structure (MappingNode wrapping),
|
|
// but 25 levels reliably exceeds MaxYAMLDepth (20) with comfortable margin.
|
|
// The test uses 25 levels rather than exactly 21 to avoid brittleness.
|
|
var sb strings.Builder
|
|
sb.WriteString("name: test\nidentity: test\nnested:\n")
|
|
indent := " "
|
|
for i := 0; i < 25; i++ {
|
|
sb.WriteString(strings.Repeat(indent, i+1))
|
|
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
|
}
|
|
sb.WriteString(strings.Repeat(indent, 26))
|
|
sb.WriteString("value: too-deep\n")
|
|
|
|
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for deeply nested YAML, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "nesting depth exceeds") {
|
|
t.Errorf("error = %q, want containing 'nesting depth exceeds'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestYAMLEmptyFileRejection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"completely_empty", ""},
|
|
{"whitespace_only", " \n\n "},
|
|
{"comment_only", "# just a comment\n"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, tc.name+".yaml")
|
|
if err := os.WriteFile(path, []byte(tc.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty YAML input, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty YAML document") {
|
|
t.Errorf("expected error containing %q, got: %v", "empty YAML document", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestYAMLFileSizeLimit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "huge.yaml")
|
|
|
|
// Create a file larger than MaxPersonaFileSize (64 KB)
|
|
content := "name: test\nidentity: " + strings.Repeat("x", MaxPersonaFileSize+1) + "\n"
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for oversized file, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
|
t.Errorf("error = %q, want containing 'exceeds maximum size'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestYAMLAliasCycleDetection(t *testing.T) {
|
|
// Test that our checkYAMLDepth function handles alias cycles gracefully
|
|
// by using the visiting map to prevent infinite recursion.
|
|
|
|
// Create a node structure where an alias points to a parent node,
|
|
// simulating what could happen with crafted input.
|
|
parent := &ast.MappingNode{
|
|
Values: []*ast.MappingValueNode{
|
|
{
|
|
Key: &ast.StringNode{Value: "name"},
|
|
Value: &ast.StringNode{Value: "test"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a child that aliases back to the parent (artificial cycle)
|
|
aliasToParent := &ast.AliasNode{
|
|
Value: parent,
|
|
}
|
|
parent.Values = append(parent.Values, &ast.MappingValueNode{
|
|
Key: &ast.StringNode{Value: "nested"},
|
|
Value: aliasToParent,
|
|
})
|
|
|
|
nodeCount := 0
|
|
validated := make(map[ast.Node]int)
|
|
visiting := make(map[ast.Node]bool)
|
|
|
|
// This should NOT hang or stack overflow - cycle detection prevents infinite recursion
|
|
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
|
|
if err != nil {
|
|
t.Errorf("unexpected error traversing cyclic structure: %v", err)
|
|
}
|
|
|
|
// Verify we tracked the parent in the validated map
|
|
if _, ok := validated[parent]; !ok {
|
|
t.Error("parent node not tracked in validated map")
|
|
}
|
|
}
|
|
|
|
func TestYAMLMultiDocumentRejection(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "multi.yaml")
|
|
|
|
// Multi-document YAML (documents separated by ---)
|
|
content := `name: first
|
|
identity: first document
|
|
---
|
|
name: second
|
|
identity: second document
|
|
`
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for multi-document YAML, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "multi-document") {
|
|
t.Errorf("error = %q, want containing 'multi-document'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestYAMLNodeCountLimit(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "wide.yaml")
|
|
|
|
// Build a YAML structure that's shallow but wide - many keys at the same level
|
|
// to test the node count limit (should exceed MaxYAMLNodes = 1000)
|
|
var sb strings.Builder
|
|
sb.WriteString("name: test\nidentity: test\n")
|
|
for i := 0; i < 600; i++ {
|
|
sb.WriteString(fmt.Sprintf("key%d: value%d\n", i, i))
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for wide YAML exceeding node count, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "node count exceeds") {
|
|
t.Errorf("error = %q, want containing 'node count exceeds'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestCheckYAMLDepthCycleDetectionDirect(t *testing.T) {
|
|
// Direct test of cycle detection in checkYAMLDepth by creating
|
|
// a node structure with an artificial cycle.
|
|
node := &ast.MappingNode{
|
|
Values: []*ast.MappingValueNode{
|
|
{
|
|
Key: &ast.StringNode{Value: "key"},
|
|
Value: &ast.StringNode{Value: "value"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a cycle by making a child reference the parent
|
|
cycleChild := &ast.AliasNode{
|
|
Value: node, // Points back to the parent
|
|
}
|
|
node.Values = append(node.Values, &ast.MappingValueNode{
|
|
Key: &ast.StringNode{Value: "cyclic"},
|
|
Value: cycleChild,
|
|
})
|
|
|
|
nodeCount := 0
|
|
validated := make(map[ast.Node]int)
|
|
visiting := make(map[ast.Node]bool)
|
|
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
|
|
|
|
// Should complete without infinite recursion due to cycle detection
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
// The validated map should contain multiple entries
|
|
if len(validated) < 2 {
|
|
t.Errorf("validated map has %d entries, expected at least 2", len(validated))
|
|
}
|
|
}
|
|
|
|
func TestYAMLAliasDepthBypass(t *testing.T) {
|
|
// Test that an anchored subtree first validated at a shallow depth is
|
|
// re-checked when referenced via alias at a deeper position. Without the
|
|
// depth-aware validated map, the alias reference would skip re-checking
|
|
// and allow the effective nesting to exceed MaxYAMLDepth.
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "alias-depth-bypass.yaml")
|
|
|
|
// Build YAML with an anchor at shallow depth containing a subtree near the limit,
|
|
// then reference it via alias deep enough that effective depth exceeds MaxYAMLDepth.
|
|
var sb strings.Builder
|
|
sb.WriteString("name: test\nidentity: test\n")
|
|
|
|
// Create the anchored subtree at depth 1 (key level) that nests 15 levels deep.
|
|
sb.WriteString("anchor_key: &deep_anchor\n")
|
|
for i := 0; i < 15; i++ {
|
|
sb.WriteString(strings.Repeat(" ", i+1))
|
|
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
|
}
|
|
sb.WriteString(strings.Repeat(" ", 16))
|
|
sb.WriteString("leaf: value\n")
|
|
|
|
// Create a wrapper that nests 6 levels deep, then references the anchor.
|
|
// Effective depth at alias target = 6 (wrapper nesting) + 1 (alias) + 15 (subtree) = 22 > 20
|
|
sb.WriteString("wrapper:\n")
|
|
for i := 0; i < 6; i++ {
|
|
sb.WriteString(strings.Repeat(" ", i+1))
|
|
sb.WriteString(fmt.Sprintf("n%d:\n", i))
|
|
}
|
|
sb.WriteString(strings.Repeat(" ", 7))
|
|
sb.WriteString("alias_ref: *deep_anchor\n")
|
|
|
|
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for alias depth bypass, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "nesting depth exceeds") {
|
|
t.Errorf("error = %q, want containing 'nesting depth exceeds'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonasSortedOrder(t *testing.T) {
|
|
names := ListBuiltinPersonas()
|
|
if len(names) < 2 {
|
|
t.Skip("need at least 2 personas to test ordering")
|
|
}
|
|
|
|
// Verify the list is sorted
|
|
for i := 1; i < len(names); i++ {
|
|
if names[i-1] > names[i] {
|
|
t.Errorf("ListBuiltinPersonas not sorted: %q > %q", names[i-1], names[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestYAMLUnknownFieldsRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "unknown top-level field",
|
|
content: `name: test
|
|
identity: test identity
|
|
unknown_field: should fail
|
|
`,
|
|
wantErr: "unknown_field",
|
|
},
|
|
{
|
|
name: "typo in field name",
|
|
content: `name: test
|
|
identiy: typo should fail
|
|
`,
|
|
wantErr: "identiy",
|
|
},
|
|
{
|
|
name: "unknown field in severity",
|
|
content: `name: test
|
|
identity: test
|
|
severity:
|
|
major: Major
|
|
minro: typo
|
|
`,
|
|
wantErr: "minro",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "unknown.yaml")
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Errorf("expected error for unknown field %q, got nil", tt.wantErr)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJSONUnknownFieldsRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "unknown top-level field",
|
|
content: `{
|
|
"name": "test",
|
|
"identity": "test identity",
|
|
"unknown_field": "should fail"
|
|
}`,
|
|
wantErr: "unknown_field",
|
|
},
|
|
{
|
|
name: "typo in field name",
|
|
content: `{
|
|
"name": "test",
|
|
"identiy": "typo should fail"
|
|
}`,
|
|
wantErr: "identiy",
|
|
},
|
|
{
|
|
name: "unknown field in severity",
|
|
content: `{
|
|
"name": "test",
|
|
"identity": "test",
|
|
"severity": {
|
|
"major": "ok",
|
|
"miner": "typo"
|
|
}
|
|
}`,
|
|
wantErr: "miner",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown field, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaSymlink(t *testing.T) {
|
|
// Create a regular persona file
|
|
dir := t.TempDir()
|
|
realFile := filepath.Join(dir, "real.yaml")
|
|
content := `name: test
|
|
identity: test identity
|
|
`
|
|
if err := os.WriteFile(realFile, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
// Create a symlink to it
|
|
symlink := filepath.Join(dir, "link.yaml")
|
|
if err := os.Symlink(realFile, symlink); err != nil {
|
|
t.Fatalf("failed to create symlink: %v", err)
|
|
}
|
|
|
|
// LoadPersona should work via symlink
|
|
p, err := LoadPersona(symlink)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona via symlink failed: %v", err)
|
|
}
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
}
|
|
|
|
func TestJSONTrailingContentRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{
|
|
name: "trailing garbage after object",
|
|
content: `{"name":"test","identity":"test identity"}garbage`,
|
|
},
|
|
{
|
|
name: "two JSON objects",
|
|
content: `{"name":"test","identity":"test identity"}{"name":"other"}`,
|
|
},
|
|
{
|
|
name: "trailing array",
|
|
content: `{"name":"test","identity":"test identity"}[]`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for trailing content, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "trailing content") {
|
|
t.Errorf("error = %q, want to contain 'trailing content'", err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePersonaBytesSizeLimit(t *testing.T) {
|
|
// ParsePersonaBytes should reject input exceeding MaxPersonaFileSize
|
|
oversized := make([]byte, MaxPersonaFileSize+1)
|
|
for i := range oversized {
|
|
oversized[i] = 'x'
|
|
}
|
|
|
|
_, err := ParsePersonaBytes(oversized, "oversized.yaml")
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized input, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
|
t.Errorf("error = %q, want to contain 'exceeds maximum size'", err.Error())
|
|
}
|
|
|
|
// Just under the limit should not trigger size error (may fail parse, but not size)
|
|
underLimit := []byte("name: test\nidentity: test persona\n")
|
|
p, err := ParsePersonaBytes(underLimit, "valid.yaml")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for valid input: %v", err)
|
|
}
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
}
|
|
|
|
func TestYAMLMergeKeyDepthCheck(t *testing.T) {
|
|
// Verify that YAML merge keys (<<: *alias) are properly handled by the
|
|
// depth checker. The merge key content is in the MappingValueNode.Value
|
|
// (an AliasNode), not in the MergeKeyNode itself.
|
|
p, err := ParsePersonaBytes([]byte("name: merge-test\nidentity: test\n"), "merge.yaml")
|
|
if err != nil {
|
|
t.Fatalf("basic parse failed: %v", err)
|
|
}
|
|
if p.Name != "merge-test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "merge-test")
|
|
}
|
|
|
|
// Test that deeply nested merge keys still hit depth limit.
|
|
// Build YAML with merge key content nested beyond MaxYAMLDepth.
|
|
var sb strings.Builder
|
|
sb.WriteString("name: deep-merge\nidentity: deep merge persona\n")
|
|
sb.WriteString("anchor: &deep\n")
|
|
indent := " "
|
|
for i := 0; i < MaxYAMLDepth+5; i++ {
|
|
sb.WriteString(indent)
|
|
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
|
indent += " "
|
|
}
|
|
sb.WriteString(indent + "leaf: value\n")
|
|
sb.WriteString("target:\n <<: *deep\n")
|
|
|
|
_, err = ParsePersonaBytes([]byte(sb.String()), "deep-merge.yaml")
|
|
if err == nil {
|
|
t.Fatal("expected error for deeply nested merge key content, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "depth") {
|
|
t.Errorf("error = %q, want to contain 'depth'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestLoadPersona_NonexistentFile(t *testing.T) {
|
|
_, err := LoadPersona("/tmp/nonexistent-persona-file-xyz.yaml")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file, got nil")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersona_NotARegularFile(t *testing.T) {
|
|
// Use a directory as the path — directories are not regular files.
|
|
dir := t.TempDir()
|
|
_, err := LoadPersona(dir)
|
|
if err == nil {
|
|
t.Fatal("expected error for directory path, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not a regular file") {
|
|
t.Errorf("error = %q, want to contain 'not a regular file'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestLoadPersona_OversizedFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "big.yaml")
|
|
// Write a file larger than MaxPersonaFileSize
|
|
data := make([]byte, MaxPersonaFileSize+1)
|
|
for i := range data {
|
|
data[i] = 'x'
|
|
}
|
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
t.Fatalf("failed to create test file: %v", err)
|
|
}
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized file, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
|
t.Errorf("error = %q, want to contain 'exceeds maximum size'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestCapitalizeFirst_RuneError(t *testing.T) {
|
|
// An invalid UTF-8 byte sequence should return the original string unchanged.
|
|
invalid := string([]byte{0xFF, 0xFE})
|
|
got := CapitalizeFirst(invalid)
|
|
if got != invalid {
|
|
t.Errorf("CapitalizeFirst(%q) = %q, want original %q", invalid, got, invalid)
|
|
}
|
|
}
|