cloudflare/pint

Public

mirrored from https://github.com/cloudflare/pintAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.81.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

internal/discovery/discovery_test.go

888lines · modecode

1package discovery
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "regexp"
11 "strings"
12 "testing"
13
14 "github.com/stretchr/testify/require"
15
16 "github.com/cloudflare/pint/internal/diags"
17 "github.com/cloudflare/pint/internal/parser"
18)
19
20func TestReadRules(t *testing.T) {
21 mustParse := func(offset int, s string) parser.Rule {
22 p := parser.NewParser(parser.DefaultOptions)
23 file := p.Parse(strings.NewReader(strings.Repeat("\n", offset) + s))
24 if file.Error.Err != nil {
25 panic(fmt.Sprintf("failed to parse rule:\n---\n%s\n---\nerror: %s", s, file.Error))
26 }
27 if len(file.Groups) != 1 {
28 panic(fmt.Sprintf("wrong number of groups returned: %d\n---\n%s\n---", len(file.Groups), s))
29 }
30 if len(file.Groups[0].Rules) != 1 {
31 panic(fmt.Sprintf("wrong number of rules returned: %d\n---\n%s\n---", len(file.Groups[0].Rules), s))
32 }
33 return file.Groups[0].Rules[0]
34 }
35
36 type testCaseT struct {
37 sourceFunc func(t *testing.T) io.Reader
38 check func(t *testing.T, entries []*Entry)
39 title string
40 reportedPath string
41 sourcePath string
42 entries []Entry
43 allowedOwners []*regexp.Regexp
44 isStrict bool
45 }
46
47 testCases := []testCaseT{
48 {
49 title: "nil input",
50 reportedPath: "rules.yml",
51 sourcePath: "rules.yml",
52 sourceFunc: func(_ *testing.T) io.Reader {
53 return bytes.NewBuffer(nil)
54 },
55 isStrict: false,
56 },
57 {
58 title: "nil input",
59 reportedPath: "rules.yml",
60 sourcePath: "rules.yml",
61 sourceFunc: func(_ *testing.T) io.Reader {
62 return bytes.NewBuffer(nil)
63 },
64 isStrict: true,
65 },
66 {
67 title: "empty input",
68 reportedPath: "rules.yml",
69 sourcePath: "rules.yml",
70 sourceFunc: func(_ *testing.T) io.Reader {
71 return bytes.NewBuffer([]byte(" "))
72 },
73 isStrict: false,
74 },
75 {
76 title: "empty input",
77 reportedPath: "rules.yml",
78 sourcePath: "rules.yml",
79 sourceFunc: func(_ *testing.T) io.Reader {
80 return bytes.NewBuffer([]byte(" "))
81 },
82 isStrict: true,
83 },
84 {
85 title: "no rules, just a comment",
86 reportedPath: "rules.yml",
87 sourcePath: "rules.yml",
88 sourceFunc: func(_ *testing.T) io.Reader {
89 return bytes.NewBuffer([]byte("\n\n # pint file/disable xxx \n\n"))
90 },
91 isStrict: false,
92 },
93 {
94 title: "file/disable comment",
95 reportedPath: "rules.yml",
96 sourcePath: "rules.yml",
97 sourceFunc: func(_ *testing.T) io.Reader {
98 return bytes.NewBuffer([]byte(`
99# pint file/disable promql/series
100
101- record: foo
102 expr: bar
103`))
104 },
105 isStrict: false,
106 entries: []Entry{
107 {
108 State: Noop,
109 Path: Path{
110 Name: "rules.yml",
111 SymlinkTarget: "rules.yml",
112 },
113 Rule: mustParse(3, "- record: foo\n expr: bar\n"),
114 DisabledChecks: []string{"promql/series"},
115 },
116 },
117 },
118 {
119 title: "file/disable comment",
120 reportedPath: "rules.yml",
121 sourcePath: "rules.yml",
122 sourceFunc: func(_ *testing.T) io.Reader {
123 return bytes.NewBuffer([]byte(`
124# pint file/disable promql/series
125
126groups:
127- name: foo
128 rules:
129 - record: foo
130 expr: bar
131`))
132 },
133 isStrict: true,
134 entries: []Entry{
135 {
136 State: Noop,
137 Path: Path{
138 Name: "rules.yml",
139 SymlinkTarget: "rules.yml",
140 },
141 Rule: mustParse(6, " - record: foo\n expr: bar\n"),
142 DisabledChecks: []string{"promql/series"},
143 },
144 },
145 },
146 {
147 title: "single expired snooze comment",
148 reportedPath: "rules.yml",
149 sourcePath: "rules.yml",
150 sourceFunc: func(_ *testing.T) io.Reader {
151 return bytes.NewBuffer([]byte(`
152# pint file/snooze 2000-01-01T00:00:00Z promql/series
153
154- record: foo
155 expr: bar
156`))
157 },
158 isStrict: false,
159 entries: []Entry{
160 {
161 State: Noop,
162 Path: Path{
163 Name: "rules.yml",
164 SymlinkTarget: "rules.yml",
165 },
166 Rule: mustParse(3, "- record: foo\n expr: bar\n"),
167 },
168 },
169 },
170 {
171 title: "single expired snooze comment",
172 reportedPath: "rules.yml",
173 sourcePath: "rules.yml",
174 sourceFunc: func(_ *testing.T) io.Reader {
175 return bytes.NewBuffer([]byte(`
176# pint file/snooze 2000-01-01T00:00:00Z promql/series
177
178groups:
179- name: foo
180 rules:
181 - record: foo
182 expr: bar
183`))
184 },
185 isStrict: true,
186 entries: []Entry{
187 {
188 State: Noop,
189 Path: Path{
190 Name: "rules.yml",
191 SymlinkTarget: "rules.yml",
192 },
193 Rule: mustParse(6, " - record: foo\n expr: bar\n"),
194 },
195 },
196 },
197 {
198 title: "single valid snooze comment",
199 reportedPath: "rules.yml",
200 sourcePath: "rules.yml",
201 sourceFunc: func(_ *testing.T) io.Reader {
202 return bytes.NewBuffer([]byte(`
203# pint file/snooze 2099-01-01T00:00:00Z promql/series
204
205- record: foo
206 expr: bar
207`))
208 },
209 isStrict: false,
210 entries: []Entry{
211 {
212 State: Noop,
213 Path: Path{
214 Name: "rules.yml",
215 SymlinkTarget: "rules.yml",
216 },
217 Rule: mustParse(3, "- record: foo\n expr: bar\n"),
218 DisabledChecks: []string{"promql/series"},
219 },
220 },
221 },
222 {
223 title: "single valid snooze comment",
224 reportedPath: "rules.yml",
225 sourcePath: "rules.yml",
226 sourceFunc: func(_ *testing.T) io.Reader {
227 return bytes.NewBuffer([]byte(`
228# pint file/snooze 2099-01-01T00:00:00Z promql/series
229
230groups:
231- name: foo
232 rules:
233 - record: foo
234 expr: bar
235`))
236 },
237 isStrict: true,
238 entries: []Entry{
239 {
240 State: Noop,
241 Path: Path{
242 Name: "rules.yml",
243 SymlinkTarget: "rules.yml",
244 },
245 Rule: mustParse(6, " - record: foo\n expr: bar\n"),
246 DisabledChecks: []string{"promql/series"},
247 },
248 },
249 },
250 {
251 title: "ignore/file",
252 reportedPath: "rules.yml",
253 sourcePath: "rules.yml",
254 sourceFunc: func(_ *testing.T) io.Reader {
255 return bytes.NewBuffer([]byte(`
256# pint ignore/file
257
258- record: foo
259 expr: bar
260`))
261 },
262 isStrict: false,
263 entries: []Entry{
264 {
265 State: Noop,
266 Path: Path{
267 Name: "rules.yml",
268 SymlinkTarget: "rules.yml",
269 },
270 PathError: FileIgnoreError{
271 Diagnostic: diags.Diagnostic{
272 Message: "This file was excluded from pint checks.",
273 Pos: diags.PositionRanges{
274 {Line: 2, FirstColumn: 1, LastColumn: 18},
275 },
276 FirstColumn: 1,
277 LastColumn: 18,
278 Kind: diags.Issue,
279 },
280 },
281 },
282 },
283 },
284 {
285 title: "ignore/file",
286 reportedPath: "rules.yml",
287 sourcePath: "rules.yml",
288 sourceFunc: func(_ *testing.T) io.Reader {
289 return bytes.NewBuffer([]byte(`
290# pint ignore/file
291
292groups:
293- name: foo
294 rules:
295 - record: foo
296 expr: bar
297`))
298 },
299 isStrict: true,
300 entries: []Entry{
301 {
302 State: Noop,
303 Path: Path{
304 Name: "rules.yml",
305 SymlinkTarget: "rules.yml",
306 },
307 PathError: FileIgnoreError{
308 Diagnostic: diags.Diagnostic{
309 Message: "This file was excluded from pint checks.",
310 Pos: diags.PositionRanges{
311 {Line: 2, FirstColumn: 1, LastColumn: 18},
312 },
313 FirstColumn: 1,
314 LastColumn: 18,
315 Kind: diags.Issue,
316 },
317 },
318 },
319 },
320 },
321 {
322 title: "invalid owner",
323 reportedPath: "rules.yml",
324 sourcePath: "rules.yml",
325 allowedOwners: []*regexp.Regexp{regexp.MustCompile("^team-")},
326 sourceFunc: func(_ *testing.T) io.Reader {
327 return bytes.NewBufferString("# pint file/owner bob\n")
328 },
329 check: func(t *testing.T, entries []*Entry) {
330 t.Helper()
331 require.Len(t, entries, 1)
332 require.Error(t, entries[0].PathError)
333 require.Contains(t, entries[0].PathError.Error(), "doesn't match any of the allowed owner values")
334 },
335 },
336 {
337 title: "invalid comment",
338 reportedPath: "rules.yml",
339 sourcePath: "rules.yml",
340 sourceFunc: func(_ *testing.T) io.Reader {
341 return bytes.NewBufferString("# pint file/owner\n\n- record: foo\n expr: sum(foo)\n")
342 },
343 check: func(t *testing.T, entries []*Entry) {
344 t.Helper()
345 var found bool
346 for _, e := range entries {
347 if e.PathError != nil {
348 found = true
349 require.Contains(t, e.PathError.Error(), "pint control comment")
350 }
351 }
352 require.True(t, found, "expected at least one entry with PathError from invalid comment")
353 },
354 },
355 {
356 title: "group error",
357 reportedPath: "rules.yml",
358 sourcePath: "rules.yml",
359 isStrict: true,
360 sourceFunc: func(_ *testing.T) io.Reader {
361 return bytes.NewBufferString("groups:\n- name: foo\n interval: bad\n rules:\n - record: foo\n expr: bar\n")
362 },
363 check: func(t *testing.T, entries []*Entry) {
364 t.Helper()
365 var found bool
366 for _, e := range entries {
367 if e.PathError != nil {
368 found = true
369 }
370 }
371 require.True(t, found, "expected at least one entry with PathError from group error")
372 },
373 },
374 {
375 title: "rule owner override",
376 reportedPath: "rules.yml",
377 sourcePath: "rules.yml",
378 sourceFunc: func(_ *testing.T) io.Reader {
379 return bytes.NewBufferString("# pint file/owner alice\n\n- record: foo\n # pint rule/owner bob\n expr: sum(foo)\n")
380 },
381 check: func(t *testing.T, entries []*Entry) {
382 t.Helper()
383 require.Len(t, entries, 1)
384 require.Equal(t, "bob", entries[0].Owner)
385 },
386 },
387 }
388
389 for _, tc := range testCases {
390 t.Run(
391 fmt.Sprintf("rPath=%s sPath=%s strict=%v title=%s", tc.reportedPath, tc.sourcePath, tc.isStrict, tc.title),
392 func(t *testing.T) {
393 r := tc.sourceFunc(t)
394 p := parser.NewParser(parser.DefaultOptions.WithStrict(tc.isStrict))
395 entries := readRules(tc.reportedPath, tc.sourcePath, r, p, tc.allowedOwners, nil)
396 if tc.check != nil {
397 tc.check(t, entries)
398 } else {
399 expected, err := json.MarshalIndent(tc.entries, "", " ")
400 require.NoError(t, err, "json(expected)")
401 got, err := json.MarshalIndent(entries, "", " ")
402 require.NoError(t, err, "json(got)")
403 require.Equal(t, string(expected), string(got))
404 }
405 },
406 )
407 }
408}
409
410func TestChangeTypeMarshalJSON(t *testing.T) {
411 type testCaseT struct {
412 name string
413 expected string
414 ct ChangeType
415 }
416
417 testCases := []testCaseT{
418 {
419 name: "noop",
420 ct: Noop,
421 expected: `"noop"`,
422 },
423 {
424 name: "undefined value",
425 ct: ChangeType(255),
426 expected: `"---"`,
427 },
428 }
429
430 for _, tc := range testCases {
431 t.Run(tc.name, func(t *testing.T) {
432 b, err := tc.ct.MarshalJSON()
433 require.NoError(t, err)
434 require.Equal(t, tc.expected, string(b))
435 })
436 }
437}
438
439func TestChangeTypeString(t *testing.T) {
440 testCases := []struct {
441 expected string
442 ct ChangeType
443 }{
444 {ct: Unknown, expected: "unknown"},
445 {ct: Noop, expected: "noop"},
446 {ct: Added, expected: "added"},
447 {ct: Modified, expected: "modified"},
448 {ct: Removed, expected: "removed"},
449 {ct: Moved, expected: "moved"},
450 {ct: ChangeType(255), expected: "---"},
451 }
452
453 for _, tc := range testCases {
454 t.Run(tc.expected, func(t *testing.T) {
455 require.Equal(t, tc.expected, tc.ct.String())
456 })
457 }
458}
459
460func TestFileIgnoreErrorError(t *testing.T) {
461 fe := FileIgnoreError{
462 Diagnostic: diags.Diagnostic{Message: "file ignored by comment"},
463 }
464 require.Equal(t, "file ignored by comment", fe.Error())
465}
466
467func TestIsValidOwner(t *testing.T) {
468 type testCaseT struct {
469 name string
470 owner string
471 valid []*regexp.Regexp
472 expected bool
473 }
474
475 testCases := []testCaseT{
476 {
477 name: "no restrictions",
478 owner: "anyone",
479 expected: true,
480 },
481 {
482 name: "matching owner",
483 owner: "team-sre",
484 valid: []*regexp.Regexp{regexp.MustCompile("^team-")},
485 expected: true,
486 },
487 {
488 name: "non-matching owner",
489 owner: "bob",
490 valid: []*regexp.Regexp{regexp.MustCompile("^team-")},
491 expected: false,
492 },
493 }
494
495 for _, tc := range testCases {
496 t.Run(tc.name, func(t *testing.T) {
497 require.Equal(t, tc.expected, isValidOwner(tc.owner, tc.valid))
498 })
499 }
500}
501
502func TestEntryLabels(t *testing.T) {
503 makeKey := func() *parser.YamlNode {
504 return &parser.YamlNode{Value: "labels"}
505 }
506 makeItems := func(keys ...string) []*parser.YamlKeyValue {
507 items := make([]*parser.YamlKeyValue, len(keys))
508 for i, k := range keys {
509 items[i] = &parser.YamlKeyValue{
510 Key: &parser.YamlNode{Value: k},
511 Value: &parser.YamlNode{Value: "v"},
512 }
513 }
514 return items
515 }
516
517 type testCaseT struct {
518 name string
519 entry Entry
520 expected int
521 }
522
523 testCases := []testCaseT{
524 {
525 name: "empty entry",
526 entry: Entry{},
527 expected: 0,
528 },
529 {
530 name: "alerting rule labels only",
531 entry: Entry{
532 Rule: parser.Rule{
533 AlertingRule: &parser.AlertingRule{
534 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("severity")},
535 },
536 },
537 },
538 expected: 1,
539 },
540 {
541 name: "alerting rule labels with group",
542 entry: Entry{
543 Rule: parser.Rule{
544 AlertingRule: &parser.AlertingRule{
545 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("severity")},
546 },
547 },
548 Group: &parser.Group{
549 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("env")},
550 },
551 },
552 expected: 2,
553 },
554 {
555 name: "recording rule labels only",
556 entry: Entry{
557 Rule: parser.Rule{
558 RecordingRule: &parser.RecordingRule{
559 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("job")},
560 },
561 },
562 },
563 expected: 1,
564 },
565 {
566 name: "recording rule labels with group",
567 entry: Entry{
568 Rule: parser.Rule{
569 RecordingRule: &parser.RecordingRule{
570 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("job")},
571 },
572 },
573 Group: &parser.Group{
574 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("env")},
575 },
576 },
577 expected: 2,
578 },
579 {
580 name: "group labels only",
581 entry: Entry{
582 Rule: parser.Rule{},
583 Group: &parser.Group{
584 Labels: &parser.YamlMap{Key: makeKey(), Items: makeItems("env")},
585 },
586 },
587 expected: 1,
588 },
589 }
590
591 for _, tc := range testCases {
592 t.Run(tc.name, func(t *testing.T) {
593 ym := tc.entry.Labels()
594 require.Len(t, ym.Items, tc.expected)
595 })
596 }
597}
598
599func TestPathString(t *testing.T) {
600 testCases := []struct {
601 title string
602 path Path
603 expected string
604 }{
605 {
606 title: "no symlink",
607 path: Path{Name: "rules.yml", SymlinkTarget: "rules.yml"},
608 expected: "rules.yml",
609 },
610 {
611 title: "with symlink",
612 path: Path{Name: "link.yml", SymlinkTarget: "rules.yml"},
613 expected: "link.yml ~> rules.yml",
614 },
615 }
616
617 for _, tc := range testCases {
618 t.Run(tc.title, func(t *testing.T) {
619 require.Equal(t, tc.expected, tc.path.String())
620 })
621 }
622}
623
624func TestFindSymlinks(t *testing.T) {
625 testCases := []struct {
626 setup func(t *testing.T)
627 cleanup func(t *testing.T)
628 title string
629 expected []symlink
630 }{
631 {
632 // Unreadable directory produces a symlink entry with err set.
633 title: "unreadable directory is captured as error",
634 setup: func(t *testing.T) {
635 require.NoError(t, os.Mkdir("noread", 0o000))
636 },
637 cleanup: func(_ *testing.T) {
638 _ = os.Chmod("noread", 0o755)
639 },
640 expected: []symlink{
641 {from: "noread", to: ""},
642 },
643 },
644 {
645 // Broken symlink (target deleted) produces a symlink entry with err set.
646 title: "broken symlink is captured as error",
647 setup: func(t *testing.T) {
648 require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
649 require.NoError(t, os.Symlink("target.txt", "link.txt"))
650 require.NoError(t, os.Remove("target.txt"))
651 },
652 expected: []symlink{
653 {from: "link.txt", to: ""},
654 },
655 },
656 }
657
658 for _, tc := range testCases {
659 t.Run(tc.title, func(t *testing.T) {
660 t.Chdir(t.TempDir())
661 tc.setup(t)
662 if tc.cleanup != nil {
663 t.Cleanup(func() { tc.cleanup(t) })
664 }
665
666 slinks := findSymlinks()
667 require.Len(t, slinks, len(tc.expected))
668 for i, got := range slinks {
669 require.Equal(t, tc.expected[i].from, got.from)
670 require.Equal(t, tc.expected[i].to, got.to)
671 require.Error(t, got.err)
672 }
673 })
674 }
675}
676
677func TestAddSymlinkedEntries(t *testing.T) {
678 testCases := []struct {
679 setup func(t *testing.T)
680 title string
681 entries []*Entry
682 expected []*Entry
683 }{
684 {
685 // Broken symlink produces an entry with PathError set.
686 title: "broken symlink is reported as path error",
687 setup: func(t *testing.T) {
688 require.NoError(t, os.Symlink("/nonexistent/path", "broken.yml"))
689 },
690 entries: []*Entry{},
691 expected: []*Entry{
692 {
693 State: Modified,
694 Path: Path{
695 Name: "broken.yml",
696 SymlinkTarget: "broken.yml",
697 },
698 PathError: errors.New("placeholder"),
699 },
700 },
701 },
702 {
703 // Removed entries are not matched against symlinks.
704 title: "skip removed entry",
705 setup: func(t *testing.T) {
706 require.NoError(t, os.WriteFile("a.yml", []byte("test"), 0o644))
707 require.NoError(t, os.Symlink("a.yml", "link.yml"))
708 },
709 entries: []*Entry{
710 {
711 State: Removed,
712 Path: Path{Name: "a.yml", SymlinkTarget: "a.yml"},
713 },
714 },
715 expected: []*Entry{},
716 },
717 {
718 // Entries with existing PathError are not duplicated through symlinks.
719 title: "skip entry with path error",
720 setup: func(t *testing.T) {
721 require.NoError(t, os.WriteFile("b.yml", []byte("test"), 0o644))
722 require.NoError(t, os.Symlink("b.yml", "link.yml"))
723 },
724 entries: []*Entry{
725 {
726 State: Noop,
727 Path: Path{Name: "b.yml", SymlinkTarget: "b.yml"},
728 PathError: errors.New("some error"),
729 },
730 },
731 expected: []*Entry{},
732 },
733 {
734 // Entries with rule parse errors are not duplicated through symlinks.
735 title: "skip entry with rule error",
736 setup: func(t *testing.T) {
737 require.NoError(t, os.WriteFile("c.yml", []byte("test"), 0o644))
738 require.NoError(t, os.Symlink("c.yml", "link.yml"))
739 },
740 entries: []*Entry{
741 {
742 State: Noop,
743 Path: Path{Name: "c.yml", SymlinkTarget: "c.yml"},
744 Rule: parser.Rule{
745 Error: parser.ParseError{Err: errors.New("parse error")},
746 },
747 },
748 },
749 expected: []*Entry{},
750 },
751 {
752 // Entries that are already symlink targets are not duplicated.
753 title: "skip entry that is already a symlink",
754 setup: func(t *testing.T) {
755 require.NoError(t, os.WriteFile("d.yml", []byte("test"), 0o644))
756 require.NoError(t, os.Symlink("d.yml", "link.yml"))
757 },
758 entries: []*Entry{
759 {
760 State: Noop,
761 Path: Path{Name: "link.yml", SymlinkTarget: "d.yml"},
762 },
763 },
764 expected: []*Entry{},
765 },
766 {
767 // Valid symlink creates a new entry pointing from the link to the target.
768 title: "symlink matches entry",
769 setup: func(t *testing.T) {
770 require.NoError(t, os.WriteFile("real.yml", []byte("test"), 0o644))
771 require.NoError(t, os.Symlink("real.yml", "link.yml"))
772 },
773 entries: []*Entry{
774 {
775 State: Noop,
776 Path: Path{Name: "real.yml", SymlinkTarget: "real.yml"},
777 Owner: "alice",
778 },
779 },
780 expected: []*Entry{
781 {
782 State: Noop,
783 Path: Path{
784 Name: "link.yml",
785 SymlinkTarget: "real.yml",
786 },
787 Owner: "alice",
788 },
789 },
790 },
791 }
792
793 for _, tc := range testCases {
794 t.Run(tc.title, func(t *testing.T) {
795 t.Chdir(t.TempDir())
796 if tc.setup != nil {
797 tc.setup(t)
798 }
799
800 result := addSymlinkedEntries(tc.entries)
801 require.Len(t, result, len(tc.expected))
802 for i, got := range result {
803 require.Equal(t, tc.expected[i].State, got.State)
804 require.Equal(t, tc.expected[i].Path, got.Path)
805 require.Equal(t, tc.expected[i].Owner, got.Owner)
806 if tc.expected[i].PathError != nil {
807 require.Error(t, got.PathError)
808 } else {
809 require.NoError(t, got.PathError)
810 }
811 }
812 })
813 }
814}
815
816func TestResolveFileInfo(t *testing.T) {
817 type testCaseT struct {
818 setup func(t *testing.T) (evalPath, statPath string)
819 title string
820 errMsg string
821 isDir bool
822 }
823
824 testCases := []testCaseT{
825 {
826 // Regular file resolves and stats without error.
827 title: "regular file",
828 setup: func(t *testing.T) (string, string) {
829 require.NoError(t, os.WriteFile("file.txt", []byte("x"), 0o644))
830 return "file.txt", "file.txt"
831 },
832 },
833 {
834 // Symlink resolves through to the target file.
835 title: "valid symlink to file",
836 setup: func(t *testing.T) (string, string) {
837 require.NoError(t, os.WriteFile("target.txt", []byte("x"), 0o644))
838 require.NoError(t, os.Symlink("target.txt", "link.txt"))
839 return "link.txt", "link.txt"
840 },
841 },
842 {
843 // Directory resolves and stats as directory.
844 title: "directory",
845 setup: func(t *testing.T) (string, string) {
846 require.NoError(t, os.Mkdir("subdir", 0o755))
847 return "subdir", "subdir"
848 },
849 isDir: true,
850 },
851 {
852 // Broken symlink causes EvalSymlinks to fail with descriptive error.
853 title: "broken symlink fails EvalSymlinks",
854 setup: func(t *testing.T) (string, string) {
855 require.NoError(t, os.Symlink("/nonexistent/target", "broken.txt"))
856 return "broken.txt", "broken.txt"
857 },
858 errMsg: "this is a symlink but target file cannot be evaluated: lstat /nonexistent: no such file or directory",
859 },
860 {
861 // EvalSymlinks resolves but stat path does not exist.
862 title: "stat path does not exist",
863 setup: func(t *testing.T) (string, string) {
864 require.NoError(t, os.WriteFile("real.txt", []byte("x"), 0o644))
865 return "real.txt", "missing.txt"
866 },
867 errMsg: "stat missing.txt: no such file or directory",
868 },
869 }
870
871 for _, tc := range testCases {
872 t.Run(tc.title, func(t *testing.T) {
873 t.Chdir(t.TempDir())
874 evalPath, statPath := tc.setup(t)
875 target, info, err := resolveFileInfo(evalPath, statPath)
876 if tc.errMsg != "" {
877 require.EqualError(t, err, tc.errMsg)
878 require.Empty(t, target)
879 require.Nil(t, info)
880 } else {
881 require.NoError(t, err)
882 require.NotEmpty(t, target)
883 require.NotNil(t, info)
884 require.Equal(t, tc.isDir, info.IsDir())
885 }
886 })
887 }
888}
889