439 lines
13 KiB
Go
439 lines
13 KiB
Go
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeDocFetcher is a mock DocFetcher for tests.
|
|
type fakeDocFetcher struct {
|
|
files map[string]string // path -> content
|
|
dirs map[string]map[string]string // dir path -> (file path -> content)
|
|
}
|
|
|
|
func (f *fakeDocFetcher) GetFileContent(_ context.Context, _, _, path string) (string, error) {
|
|
if content, ok := f.files[path]; ok {
|
|
return content, nil
|
|
}
|
|
return "", errors.New("file not found: " + path)
|
|
}
|
|
|
|
func (f *fakeDocFetcher) GetAllFilesInPath(_ context.Context, _, _, path string) (map[string]string, error) {
|
|
if files, ok := f.dirs[path]; ok {
|
|
return files, nil
|
|
}
|
|
// Return empty (not an error) for unknown directories.
|
|
return nil, nil
|
|
}
|
|
|
|
// ============================================================
|
|
// ParseDocMapConfig
|
|
// ============================================================
|
|
|
|
func TestParseDocMapConfig_Valid(t *testing.T) {
|
|
yaml := `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
- paths:
|
|
- "lib/bar/**"
|
|
- "lib/baz.go"
|
|
docs:
|
|
- docs/bar.md
|
|
- docs/shared/
|
|
`
|
|
f := writeTempYAML(t, yaml)
|
|
cfg, err := ParseDocMapConfig(f)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 2 {
|
|
t.Fatalf("expected 2 mappings, got %d", len(cfg.Mappings))
|
|
}
|
|
if cfg.Mappings[0].Paths[0] != "lib/foo/**" {
|
|
t.Errorf("unexpected path: %q", cfg.Mappings[0].Paths[0])
|
|
}
|
|
if cfg.Mappings[1].Docs[1] != "docs/shared/" {
|
|
t.Errorf("unexpected doc: %q", cfg.Mappings[1].Docs[1])
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_InvalidYAML(t *testing.T) {
|
|
f := writeTempYAML(t, "mappings: [{{invalid")
|
|
_, err := ParseDocMapConfig(f)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid YAML, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_EmptyMappings(t *testing.T) {
|
|
f := writeTempYAML(t, "mappings: []\n")
|
|
cfg, err := ParseDocMapConfig(f)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 0 {
|
|
t.Errorf("expected 0 mappings, got %d", len(cfg.Mappings))
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_UnknownKeys(t *testing.T) {
|
|
// Unknown keys should produce a warning but not fail.
|
|
yaml := `
|
|
mappings:
|
|
- paths: ["lib/foo/**"]
|
|
docs: ["docs/foo.md"]
|
|
extra_key: ignored
|
|
`
|
|
f := writeTempYAML(t, yaml)
|
|
// Should succeed (lenient parsing).
|
|
cfg, err := ParseDocMapConfig(f)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for unknown keys: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 1 {
|
|
t.Errorf("expected 1 mapping, got %d", len(cfg.Mappings))
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_FileNotFound(t *testing.T) {
|
|
_, err := ParseDocMapConfig("/nonexistent/path/doc-map.yml")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file, got nil")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// MatchDocs
|
|
// ============================================================
|
|
|
|
func TestMatchDocs_NoMatch(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{"docs/foo.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/bar/baz.go"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected no matches, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_SingleMatch(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{"docs/foo.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/bar.go"})
|
|
if len(got) != 1 || got[0] != "docs/foo.md" {
|
|
t.Errorf("expected [docs/foo.md], got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_MultipleMatchesDeduplicated(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{"docs/shared.md", "docs/foo.md"}},
|
|
{Paths: []string{"lib/bar/**"}, Docs: []string{"docs/shared.md", "docs/bar.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/a.go", "lib/bar/b.go"})
|
|
// Both match; docs/shared.md should appear only once.
|
|
wantSet := map[string]bool{
|
|
"docs/shared.md": true,
|
|
"docs/foo.md": true,
|
|
"docs/bar.md": true,
|
|
}
|
|
if len(got) != 3 {
|
|
t.Errorf("expected 3 docs, got %d: %v", len(got), got)
|
|
}
|
|
for _, d := range got {
|
|
if !wantSet[d] {
|
|
t.Errorf("unexpected doc: %q", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_EmptyPaths(t *testing.T) {
|
|
// Mapping with empty paths list should not match anything.
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{}, Docs: []string{"docs/foo.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/bar.go"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected no matches for empty paths, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_EmptyDocs(t *testing.T) {
|
|
// Mapping with empty docs list should produce nothing.
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/bar.go"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected no docs for empty docs list, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_ExactMatch(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/baz.go"}, Docs: []string{"docs/baz.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/baz.go"})
|
|
if len(got) != 1 || got[0] != "docs/baz.md" {
|
|
t.Errorf("expected [docs/baz.md], got %v", got)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// globMatch
|
|
// ============================================================
|
|
|
|
func TestGlobMatch(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
path string
|
|
want bool
|
|
}{
|
|
{"exact match", "lib/foo/bar.go", "lib/foo/bar.go", true},
|
|
{"exact no match", "lib/foo/bar.go", "lib/foo/baz.go", false},
|
|
{"star wildcard", "lib/foo/*.go", "lib/foo/bar.go", true},
|
|
{"star no match cross-dir", "lib/foo/*.go", "lib/foo/sub/bar.go", false},
|
|
{"trailing doublestar", "lib/foo/**", "lib/foo/bar.go", true},
|
|
{"trailing doublestar nested", "lib/foo/**", "lib/foo/sub/deep/bar.go", true},
|
|
// Note: trailing ** matches the parent path too; PR file lists contain file paths
|
|
// (not directories), so this corner case does not arise in practice.
|
|
{"trailing doublestar matches parent", "lib/foo/**", "lib/foo", true},
|
|
{"doublestar in middle", "lib/**/bar.go", "lib/foo/sub/bar.go", true},
|
|
{"doublestar in middle no match", "lib/**/bar.go", "lib/foo/sub/baz.go", false},
|
|
{"leading doublestar", "**/bar.go", "lib/foo/bar.go", true},
|
|
{"leading doublestar top-level", "**/bar.go", "bar.go", true},
|
|
{"question mark", "lib/foo/ba?.go", "lib/foo/bar.go", true},
|
|
{"question mark no match", "lib/foo/ba?.go", "lib/foo/ba.go", false},
|
|
{"star matches none in segment", "lib/*/bar.go", "lib/bar.go", false},
|
|
{"star single segment", "lib/*/bar.go", "lib/foo/bar.go", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := globMatch(tc.pattern, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("globMatch(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// LoadMatchingDocs
|
|
// ============================================================
|
|
|
|
func TestLoadMatchingDocs_FileInjection(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/foo.md": "# Foo Design\n\nThis is the foo doc.",
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/foo.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "# Foo Design") {
|
|
t.Errorf("expected doc content, got: %q", content)
|
|
}
|
|
if !strings.Contains(content, "### docs/foo.md") {
|
|
t.Errorf("expected heading with path, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_MissingFileSkipped(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/present.md": "present",
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/missing.md", "docs/present.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "present") {
|
|
t.Errorf("expected present doc content, got: %q", content)
|
|
}
|
|
// Missing file should be skipped, not cause a failure.
|
|
}
|
|
|
|
func TestLoadMatchingDocs_DirectoryExpansion(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
dirs: map[string]map[string]string{
|
|
"docs/domain/": {
|
|
"docs/domain/a.md": "# A",
|
|
"docs/domain/b.md": "# B",
|
|
"docs/domain/c.go": "package domain", // should be skipped (not .md)
|
|
},
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/domain/"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "# A") {
|
|
t.Errorf("expected doc A content, got: %q", content)
|
|
}
|
|
if !strings.Contains(content, "# B") {
|
|
t.Errorf("expected doc B content, got: %q", content)
|
|
}
|
|
if strings.Contains(content, "package domain") {
|
|
t.Errorf("non-.md file should not be injected, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_DirectoryNoMDFiles(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
dirs: map[string]map[string]string{
|
|
"src/": {
|
|
"src/main.go": "package main",
|
|
},
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"src/"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "" {
|
|
t.Errorf("expected empty content for dir with no .md files, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_NoMatchingPaths(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "" {
|
|
t.Errorf("expected empty content for no paths, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_ContextSizeGuard(t *testing.T) {
|
|
bigContent := strings.Repeat("x", 200)
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/a.md": bigContent,
|
|
"docs/b.md": bigContent,
|
|
"docs/c.md": bigContent,
|
|
},
|
|
}
|
|
// Limit to 350 bytes — enough for a.md fully and part of b.md.
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/a.md", "docs/b.md", "docs/c.md"}, DocMapOptions{MaxBytes: 350})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(content) > 600 {
|
|
t.Errorf("content too large, expected ≤600 bytes total, got %d", len(content))
|
|
}
|
|
if !strings.Contains(content, "truncated") {
|
|
t.Errorf("expected truncation notice, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_Deduplication(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/shared.md": "shared content",
|
|
},
|
|
}
|
|
// MatchDocs deduplicates before calling LoadMatchingDocs, but test it with
|
|
// duplicates in input too.
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/shared.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "shared content") {
|
|
t.Errorf("expected shared content, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestValidateDocPath(t *testing.T) {
|
|
valid := []string{
|
|
"docs/design.md",
|
|
"docs/domain/contexts/risk/risk-controls.md",
|
|
"README.md",
|
|
"a/b/c",
|
|
}
|
|
for _, p := range valid {
|
|
if err := validateDocPath(p); err != nil {
|
|
t.Errorf("expected valid path %q to pass, got error: %v", p, err)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"/etc/passwd",
|
|
"/docs/design.md",
|
|
"docs/../../../etc/passwd",
|
|
"../sibling-repo/file.md",
|
|
"a/b/../c",
|
|
}
|
|
for _, p := range invalid {
|
|
if err := validateDocPath(p); err == nil {
|
|
t.Errorf("expected path %q to be rejected, but it was accepted", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_PathTraversalRejected(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"../secret.md": "should not be fetched",
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"../secret.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected hard error: %v", err)
|
|
}
|
|
// Bad path should be skipped (warned), not injected.
|
|
if strings.Contains(content, "should not be fetched") {
|
|
t.Errorf("path traversal doc was injected, expected it to be skipped")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Helpers
|
|
// ============================================================
|
|
|
|
func writeTempYAML(t *testing.T, content string) string {
|
|
t.Helper()
|
|
f, err := os.CreateTemp(t.TempDir(), "doc-map-*.yml")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(content); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
return filepath.Clean(f.Name())
|
|
}
|