Skip to content

Commit 956f184

Browse files
committed
skillinject: harden tests — concurrency, malformed manifest, mode drift
Adds five edge-case tests: - TestTick_ConcurrentDoesNotCorrupt: 8 goroutines tick at once against the same fakeRepo, final content matches one consistent body. -race clean. - TestTick_MalformedManifest: invalid JSON returns error and writes nothing to disk. - TestTick_HelperFetchFails_DoesNotBlockTools: a missing helper src records an error outcome but the skill+marker for tools still get reconciled. - TestParseFileMode_Defaults: '', '0', and 'garbage' fall back to 0755 instead of writing an unreadable file. - TestHelper_ModeDriftRewritesFile: chmod a helper down to 0644 and the next tick restores 0755 from the manifest declaration.
1 parent 46c7616 commit 956f184

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

pkg/skillinject/skillinject_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package skillinject
44

55
import (
66
"context"
7+
"net/http"
78
"os"
89
"path/filepath"
910
"strings"
@@ -334,6 +335,167 @@ func TestMarker_IsDirective(t *testing.T) {
334335
}
335336
}
336337

338+
// Concurrent Tick calls don't corrupt outputs. Two writers using the
339+
// same path+".tmp" could race and leave a half-written file or a
340+
// rename-source-not-found. We don't *protect* against this in the
341+
// daemon (only one Run loop fires Tick at a time), but pilotctl skills
342+
// check + a tick coincide can race; this test verifies the worst that
343+
// happens is one writer's content wins, never corruption.
344+
func TestTick_ConcurrentDoesNotCorrupt(t *testing.T) {
345+
home := t.TempDir()
346+
mustMkdirAll(t, filepath.Join(home, ".claude"))
347+
348+
r := newFakeRepo(t)
349+
r.withTools(claudeOnly())
350+
r.manifest.Helpers = []ManifestHelper{{
351+
Name: "pilot-ask",
352+
Src: "workflow-injection/pilot-ask",
353+
Dst: "~/.pilot/bin/pilot-ask",
354+
Mode: "0755",
355+
}}
356+
r.files["workflow-injection/pilot-ask"] = []byte("#!/usr/bin/env bash\necho concurrent\n")
357+
358+
const N = 8
359+
errCh := make(chan error, N)
360+
for i := 0; i < N; i++ {
361+
go func() {
362+
_, err := Tick(context.Background(), r.cfg(home))
363+
errCh <- err
364+
}()
365+
}
366+
for i := 0; i < N; i++ {
367+
if err := <-errCh; err != nil {
368+
t.Errorf("concurrent Tick %d: %v", i, err)
369+
}
370+
}
371+
372+
// Final content matches one consistent body.
373+
got := mustRead(t, filepath.Join(home, ".pilot", "bin", "pilot-ask"))
374+
if got != "#!/usr/bin/env bash\necho concurrent\n" {
375+
t.Errorf("corrupted helper content: %q", got)
376+
}
377+
skill := mustRead(t, filepath.Join(home, ".claude", "skills", "pilotctl", "SKILL.md"))
378+
if skill != testContent {
379+
t.Errorf("corrupted skill content: %q", skill[:min(80, len(skill))])
380+
}
381+
}
382+
383+
// Malformed manifest JSON: tick returns an error and writes nothing.
384+
func TestTick_MalformedManifest(t *testing.T) {
385+
home := t.TempDir()
386+
mustMkdirAll(t, filepath.Join(home, ".claude"))
387+
388+
r := newFakeRepo(t)
389+
r.files["inject-manifest.json"] = []byte("not json {{{")
390+
// Override default JSON marshaling: register a raw handler.
391+
r.srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
392+
path := strings.TrimPrefix(req.URL.Path, "/")
393+
if body, ok := r.files[path]; ok {
394+
w.Write(body)
395+
return
396+
}
397+
http.NotFound(w, req)
398+
})
399+
400+
rep, err := Tick(context.Background(), r.cfg(home))
401+
if err == nil {
402+
t.Errorf("expected error on malformed manifest, got nil")
403+
}
404+
if rep != nil && len(rep.Outcomes) > 0 {
405+
t.Errorf("expected no outcomes on parse failure, got %+v", rep.Outcomes)
406+
}
407+
mustNotExist(t, filepath.Join(home, ".pilot", "bin", "pilot-ask"))
408+
mustNotExist(t, filepath.Join(home, ".claude", "skills", "pilotctl", "SKILL.md"))
409+
}
410+
411+
// Helper fetch fails mid-tick — record an error outcome, but still
412+
// continue with skill+marker reconciliation for the tools.
413+
func TestTick_HelperFetchFails_DoesNotBlockTools(t *testing.T) {
414+
home := t.TempDir()
415+
mustMkdirAll(t, filepath.Join(home, ".claude"))
416+
417+
r := newFakeRepo(t)
418+
r.withTools(claudeOnly())
419+
// Manifest references a helper src path that's NOT served.
420+
r.manifest.Helpers = []ManifestHelper{{
421+
Name: "pilot-ask",
422+
Src: "workflow-injection/missing-helper",
423+
Dst: "~/.pilot/bin/pilot-ask",
424+
Mode: "0755",
425+
}}
426+
427+
rep, err := Tick(context.Background(), r.cfg(home))
428+
if err != nil {
429+
t.Fatalf("Tick: %v", err)
430+
}
431+
var helperErr, skillCreate bool
432+
for _, o := range rep.Outcomes {
433+
if o.Kind == KindHelper && o.Action == ActionError {
434+
helperErr = true
435+
}
436+
if o.Kind == KindSkill && o.Action == ActionCreate {
437+
skillCreate = true
438+
}
439+
}
440+
if !helperErr {
441+
t.Errorf("expected helper error outcome, got %+v", rep.Outcomes)
442+
}
443+
if !skillCreate {
444+
t.Errorf("skill should still be created despite helper failure: %+v", rep.Outcomes)
445+
}
446+
mustNotExist(t, filepath.Join(home, ".pilot", "bin", "pilot-ask"))
447+
mustExist(t, filepath.Join(home, ".claude", "skills", "pilotctl", "SKILL.md"))
448+
}
449+
450+
// Mode field defaults to 0755 when absent or invalid.
451+
func TestParseFileMode_Defaults(t *testing.T) {
452+
cases := map[string]os.FileMode{
453+
"": 0o755,
454+
"0755": 0o755,
455+
"0644": 0o644,
456+
"0700": 0o700,
457+
"garbage": 0o755,
458+
"0": 0o755, // zero is treated as default to avoid silently writing unreadable files
459+
}
460+
for input, want := range cases {
461+
if got := parseFileMode(input); got != want {
462+
t.Errorf("parseFileMode(%q) = %o, want %o", input, got, want)
463+
}
464+
}
465+
}
466+
467+
// Helper mode is preserved across ticks: chmod down then back up should
468+
// settle at the manifest-declared mode without rewriting content.
469+
func TestHelper_ModeDriftRewritesFile(t *testing.T) {
470+
home := t.TempDir()
471+
mustMkdirAll(t, filepath.Join(home, ".claude"))
472+
473+
r := newFakeRepo(t)
474+
r.withTools(claudeOnly())
475+
r.manifest.Helpers = []ManifestHelper{{
476+
Name: "pilot-ask", Src: "workflow-injection/pilot-ask",
477+
Dst: "~/.pilot/bin/pilot-ask", Mode: "0755",
478+
}}
479+
r.files["workflow-injection/pilot-ask"] = []byte("#!/usr/bin/env bash\necho ok\n")
480+
481+
if _, err := Tick(context.Background(), r.cfg(home)); err != nil {
482+
t.Fatalf("Tick 1: %v", err)
483+
}
484+
dst := filepath.Join(home, ".pilot", "bin", "pilot-ask")
485+
// User chmods down to 0644
486+
if err := os.Chmod(dst, 0o644); err != nil {
487+
t.Fatalf("chmod: %v", err)
488+
}
489+
// Next tick should detect mode drift and restore 0755.
490+
if _, err := Tick(context.Background(), r.cfg(home)); err != nil {
491+
t.Fatalf("Tick 2: %v", err)
492+
}
493+
st, _ := os.Stat(dst)
494+
if got := st.Mode().Perm(); got != 0o755 {
495+
t.Errorf("mode after rewrite = %o, want 0755", got)
496+
}
497+
}
498+
337499
// Helpers (~/.pilot/bin/pilot-ask) are installed once per tick with the
338500
// requested mode, then become noop on subsequent ticks while content
339501
// is unchanged, and rewrite when the upstream content drifts.

0 commit comments

Comments
 (0)