Skip to content

Commit 9e80345

Browse files
committed
chore: More table-driven tests
1 parent f171bec commit 9e80345

10 files changed

Lines changed: 2515 additions & 455 deletions

File tree

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
*.dll
55
*.so
66
*.dylib
7-
fleet-plan
8-
7+
/fleet-plan
8+
coverage.txt
99

1010
# Test binary, built with `go test -c`
1111
*.test

README.md

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
![fleet-plan terminal diff](assets/screenshot.png)
1616

17+
## Disclaimer
18+
19+
This is just a side project I had fun working on. This is **not** an official product associated with any company. Be sure to review the code before executing random binaries from the internet...
20+
1721
## Features
1822

1923
- **Semantic diffs** — Policies, queries, software packages, Fleet-maintained apps, App Store apps, and MDM profiles.
@@ -165,14 +169,14 @@ rules:
165169
166170
**Built-in rules** (always active, no config needed):
167171
168-
| Rule | Severity |
169-
|------|----------|
170-
| Policy platform must be `darwin`, `windows`, `linux`, or `chrome` | error |
171-
| Policy must have a name | error |
172-
| Policy must have a query | error |
173-
| Query platform must be `darwin`, `windows`, `linux`, or `chrome` | error |
174-
| Query must have a name | error |
175-
| Query logging must be `snapshot`, `differential`, or `differential_ignore_removals` | error |
172+
| Rule | Severity |
173+
| ----------------------------------------------------------------------------------- | -------- |
174+
| Policy platform must be `darwin`, `windows`, `linux`, or `chrome` | error |
175+
| Policy must have a name | error |
176+
| Policy must have a query | error |
177+
| Query platform must be `darwin`, `windows`, `linux`, or `chrome` | error |
178+
| Query must have a name | error |
179+
| Query logging must be `snapshot`, `differential`, or `differential_ignore_removals` | error |
176180

177181
**Rule conditions:** `not_empty`, `one_of`, `matches`, `exists`
178182

@@ -182,10 +186,10 @@ See [`.fleet-rules.example.yml`](.fleet-rules.example.yml) for a complete exampl
182186

183187
Auth resolves with this priority: **flags → env vars → config file**.
184188

185-
| Source | URL | Token |
186-
|--------|-----|-------|
187-
| Flags | `--url` | `--token` |
188-
| Env vars | `FLEET_PLAN_URL` | `FLEET_PLAN_TOKEN` |
189+
| Source | URL | Token |
190+
| ----------- | --------------------------- | --------------------------- |
191+
| Flags | `--url` | `--token` |
192+
| Env vars | `FLEET_PLAN_URL` | `FLEET_PLAN_TOKEN` |
189193
| Config file | `~/.config/fleet-plan.json` | `~/.config/fleet-plan.json` |
190194

191195
Config file structure:
@@ -238,16 +242,16 @@ Use `--format json` for machine-readable output that AI agents can parse.
238242

239243
`fleet-plan diff` calls only these read-only endpoints:
240244

241-
| Method | Endpoint | Purpose |
242-
|--------|----------|---------|
243-
| `GET` | `/api/v1/fleet/teams` | Team list and managed software definitions |
244-
| `GET` | `/api/v1/fleet/labels` | Label validation and host counts |
245-
| `GET` | `/api/v1/fleet/teams/{id}/policies` | Per-team policies |
246-
| `GET` | `/api/v1/fleet/queries` | Per-team queries |
247-
| `GET` | `/api/v1/fleet/mdm/profiles` | MDM configuration profiles |
248-
| `GET` | `/api/v1/fleet/software/titles` | Managed software titles (paginated) |
249-
| `GET` | `/api/v1/fleet/software/fleet_maintained_apps` | Fleet-maintained app catalog (paginated) |
250-
| `GET` | `/api/v1/fleet/policies` | Global/no-team policies (fallback) |
245+
| Method | Endpoint | Purpose |
246+
| ------ | ---------------------------------------------- | ------------------------------------------ |
247+
| `GET` | `/api/v1/fleet/teams` | Team list and managed software definitions |
248+
| `GET` | `/api/v1/fleet/labels` | Label validation and host counts |
249+
| `GET` | `/api/v1/fleet/teams/{id}/policies` | Per-team policies |
250+
| `GET` | `/api/v1/fleet/queries` | Per-team queries |
251+
| `GET` | `/api/v1/fleet/mdm/profiles` | MDM configuration profiles |
252+
| `GET` | `/api/v1/fleet/software/titles` | Managed software titles (paginated) |
253+
| `GET` | `/api/v1/fleet/software/fleet_maintained_apps` | Fleet-maintained app catalog (paginated) |
254+
| `GET` | `/api/v1/fleet/policies` | Global/no-team policies (fallback) |
251255

252256
`fleet-plan validate` is fully offline and does not call the Fleet API.
253257

@@ -271,10 +275,6 @@ Tests use the `testdata/` directory as a shared fleet-gitops fixture. Parser, di
271275

272276
Open an issue on [GitHub Issues](https://github.com/fleet-plan/fleet-plan/issues).
273277

274-
## Disclaimer
275-
276-
This is an independent community project. It is **not** an official Fleet product and is **not** affiliated with or endorsed by [Fleet Device Management Inc.](https://fleetdm.com/)
277-
278278
## License
279279

280280
[MIT](LICENSE)

cmd/fleet-plan/cmd_test.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// testdataRoot finds the repo root and returns testdata/ path.
13+
func testdataRoot(t *testing.T) string {
14+
t.Helper()
15+
_, thisFile, _, ok := runtime.Caller(0)
16+
if !ok {
17+
t.Fatal("could not determine test file path")
18+
}
19+
dir := filepath.Dir(thisFile)
20+
for {
21+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
22+
break
23+
}
24+
parent := filepath.Dir(dir)
25+
if parent == dir {
26+
t.Fatal("could not find repo root")
27+
}
28+
dir = parent
29+
}
30+
return filepath.Join(dir, "testdata")
31+
}
32+
33+
// ---------- validate command ----------
34+
35+
func TestValidateCommand(t *testing.T) {
36+
root := testdataRoot(t)
37+
38+
tests := []struct {
39+
name string
40+
args []string
41+
wantErr bool
42+
wantAll []string // substrings in stdout
43+
wantNone []string
44+
}{
45+
{
46+
name: "terminal format with testdata",
47+
args: []string{"validate", "--repo", root},
48+
// testdata has a policy with empty resolution that triggers a custom rule error
49+
wantErr: true,
50+
wantAll: []string{"error"},
51+
},
52+
{
53+
name: "json format",
54+
args: []string{"validate", "--repo", root, "--format", "json"},
55+
wantErr: true,
56+
wantAll: []string{"{", "violations"},
57+
},
58+
{
59+
name: "markdown format",
60+
args: []string{"validate", "--repo", root, "--format", "markdown"},
61+
wantErr: true,
62+
wantAll: []string{"fleet-plan validate"},
63+
},
64+
{
65+
name: "custom rules file",
66+
args: []string{"validate", "--repo", root, "--rules", ".fleet-rules.yml"},
67+
wantErr: true,
68+
wantAll: []string{"error"},
69+
},
70+
{
71+
name: "nonexistent rules file uses builtins only",
72+
args: []string{"validate", "--repo", root, "--rules", "nonexistent.yml"},
73+
wantErr: false, // builtins only, testdata passes builtins
74+
},
75+
{
76+
name: "nonexistent repo path",
77+
args: []string{"validate", "--repo", "/nonexistent/path"},
78+
wantErr: true,
79+
},
80+
}
81+
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
// Reset global flags to defaults
85+
flagRepo = "."
86+
flagFormat = "terminal"
87+
flagQuiet = false
88+
flagVerbose = false
89+
flagPirate = false
90+
flagRules = ".fleet-rules.yml"
91+
flagTeam = ""
92+
93+
// Capture stdout
94+
old := os.Stdout
95+
r, w, _ := os.Pipe()
96+
os.Stdout = w
97+
98+
// Build and execute the root command
99+
root := buildRootCmd()
100+
root.SetArgs(tt.args)
101+
err := root.Execute()
102+
103+
w.Close()
104+
var buf bytes.Buffer
105+
buf.ReadFrom(r)
106+
os.Stdout = old
107+
108+
output := buf.String()
109+
110+
if tt.wantErr && err == nil {
111+
t.Fatalf("expected error, got output:\n%s", output)
112+
}
113+
if !tt.wantErr && err != nil {
114+
t.Fatalf("unexpected error: %v\noutput:\n%s", err, output)
115+
}
116+
117+
for _, want := range tt.wantAll {
118+
if !strings.Contains(output, want) {
119+
t.Errorf("expected %q in output, got:\n%s", want, output)
120+
}
121+
}
122+
for _, notWant := range tt.wantNone {
123+
if strings.Contains(output, notWant) {
124+
t.Errorf("did not expect %q in output, got:\n%s", notWant, output)
125+
}
126+
}
127+
})
128+
}
129+
}
130+
131+
// ---------- version command ----------
132+
133+
func TestVersionCommand(t *testing.T) {
134+
old := os.Stdout
135+
r, w, _ := os.Pipe()
136+
os.Stdout = w
137+
138+
root := buildRootCmd()
139+
root.SetArgs([]string{"version"})
140+
err := root.Execute()
141+
142+
w.Close()
143+
var buf bytes.Buffer
144+
buf.ReadFrom(r)
145+
os.Stdout = old
146+
147+
if err != nil {
148+
t.Fatalf("version command error: %v", err)
149+
}
150+
151+
output := buf.String()
152+
if !strings.Contains(output, "fleet-plan") {
153+
t.Errorf("version output should contain 'fleet-plan', got:\n%s", output)
154+
}
155+
}
156+
157+
// ---------- fortune command ----------
158+
159+
func TestFortuneCommand(t *testing.T) {
160+
// Point HOME to temp dir for achievement tracking
161+
t.Setenv("HOME", t.TempDir())
162+
163+
old := os.Stdout
164+
r, w, _ := os.Pipe()
165+
os.Stdout = w
166+
167+
root := buildRootCmd()
168+
root.SetArgs([]string{"fortune"})
169+
err := root.Execute()
170+
171+
w.Close()
172+
var buf bytes.Buffer
173+
buf.ReadFrom(r)
174+
os.Stdout = old
175+
176+
if err != nil {
177+
t.Fatalf("fortune command error: %v", err)
178+
}
179+
180+
output := buf.String()
181+
if strings.TrimSpace(output) == "" {
182+
t.Error("fortune should produce output")
183+
}
184+
}
185+
186+
// ---------- unknown command ----------
187+
188+
func TestUnknownCommand(t *testing.T) {
189+
root := buildRootCmd()
190+
root.SetArgs([]string{"nonexistent-command"})
191+
err := root.Execute()
192+
if err == nil {
193+
t.Error("expected error for unknown command")
194+
}
195+
}
196+
197+
// ---------- global flags ----------
198+
199+
func TestGlobalFlags(t *testing.T) {
200+
tests := []struct {
201+
name string
202+
args []string
203+
check func(t *testing.T)
204+
}{
205+
{
206+
name: "quiet flag",
207+
args: []string{"--quiet", "version"},
208+
check: func(t *testing.T) {
209+
if !flagQuiet {
210+
t.Error("flagQuiet should be true")
211+
}
212+
},
213+
},
214+
{
215+
name: "verbose flag",
216+
args: []string{"--verbose", "version"},
217+
check: func(t *testing.T) {
218+
if !flagVerbose {
219+
t.Error("flagVerbose should be true")
220+
}
221+
},
222+
},
223+
{
224+
name: "format flag",
225+
args: []string{"--format", "json", "version"},
226+
check: func(t *testing.T) {
227+
if flagFormat != "json" {
228+
t.Errorf("flagFormat: got %q, want json", flagFormat)
229+
}
230+
},
231+
},
232+
{
233+
name: "repo flag",
234+
args: []string{"--repo", "/custom/path", "version"},
235+
check: func(t *testing.T) {
236+
if flagRepo != "/custom/path" {
237+
t.Errorf("flagRepo: got %q", flagRepo)
238+
}
239+
},
240+
},
241+
}
242+
243+
for _, tt := range tests {
244+
t.Run(tt.name, func(t *testing.T) {
245+
// Reset flags
246+
flagRepo = "."
247+
flagFormat = "terminal"
248+
flagQuiet = false
249+
flagVerbose = false
250+
flagPirate = false
251+
252+
old := os.Stdout
253+
r, w, _ := os.Pipe()
254+
os.Stdout = w
255+
256+
root := buildRootCmd()
257+
root.SetArgs(tt.args)
258+
root.Execute()
259+
260+
w.Close()
261+
var buf bytes.Buffer
262+
buf.ReadFrom(r)
263+
os.Stdout = old
264+
265+
tt.check(t)
266+
})
267+
}
268+
}

cmd/fleet-plan/main.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ var (
3333
flagPirate bool
3434
)
3535

36-
func main() {
36+
// buildRootCmd constructs the root cobra.Command with all subcommands and flags.
37+
// Extracted from main() so tests can call it without os.Exit.
38+
func buildRootCmd() *cobra.Command {
3739
root := &cobra.Command{
3840
Use: "fleet-plan",
3941
Short: "terraform plan, but for your device fleet",
@@ -72,7 +74,11 @@ Use 'fleet-plan validate' to check YAML against policy rules.`,
7274
root.AddCommand(fortuneCmd())
7375
root.AddCommand(achievementsCmd())
7476

75-
if err := root.Execute(); err != nil {
77+
return root
78+
}
79+
80+
func main() {
81+
if err := buildRootCmd().Execute(); err != nil {
7682
msg := ui.FriendlyError(err)
7783
fmt.Fprintln(os.Stderr, msg)
7884
os.Exit(1)

0 commit comments

Comments
 (0)