Files
zero/internal/cli/cron_test.go
gnanam1990 0c37093974 audit: address PR review findings (security, correctness, tests)
Fixes from the code review on the critical/high audit branch:

- tools/apply_patch: parseHunkCounts now reads only the range section between
  the opening and closing "@@". A crafted section heading like
  "@@ -1,1 +1,1 @@ +1,999999" could otherwise overwrite the line count, wedge
  the parser in hunk mode, and swallow later file headers so they escaped
  validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
- providerhealth: the SSRF-hardened dial now tries every validated address in
  order instead of pinning addrs[0], so a dual-stack/multi-record provider isn't
  failed by a single dead record. Extracted dialValidatedAddrs for coverage.
- hooks/dispatch: a beforeTool hook killed by its deadline now fails CLOSED
  (vetoes the tool) instead of fail-open. A launch failure (missing binary)
  still fails open so a misconfigured hook can't wedge every tool call.
- cron: Store.Get returns a distinct ErrJobNotFound sentinel; cron_run only
  treats that as "removed during run" and still records/persists on a transient
  read error instead of silently dropping the run.
- providers/anthropic: closeOpen finalizes thinking buffers still open at stream
  end (SSE ended after thinking_delta/signature_delta but before
  content_block_stop), so they survive in the done event's ReasoningBlocks for
  replay. Finalized in index order.
- providers/gemini: corrected the stale thinkingConfig comment (no IncludeThoughts
  field; thought summaries are suppressed by skipping part.Thought parts).

Tests: regression coverage for each fix, plus the requested nitpicks —
EOF-terminated MCP line, recipe-prompt propagation assertion, checked
file.Close() in session store tests, and a shortened bash process-group timeout
test via the bashWaitDelay hook.

Note: declined the suggestion to replay a "signature" on redacted_thinking
blocks — Anthropic's redacted_thinking carries only an opaque "data" field; the
signature is for regular thinking blocks, and the stream never populates one for
redacted blocks.
2026-06-11 15:02:09 +05:30

157 lines
4.9 KiB
Go

package cli
import (
"bytes"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/cron"
)
func testCronStore(t *testing.T) *cron.Store {
t.Helper()
return cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }})
}
func TestCronAddListRemove(t *testing.T) {
store := testCronStore(t)
var out, errb bytes.Buffer
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
if code := runCronWith(store, now, []string{"add", "0 9 * * *", "--prompt", "daily"}, &out, &errb); code != 0 {
t.Fatalf("add exit=%d err=%s", code, errb.String())
}
jobs, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(jobs) != 1 || jobs[0].Expr != "0 9 * * *" || jobs[0].Prompt != "daily" {
t.Fatalf("job not stored: %+v", jobs)
}
if jobs[0].NextRunAt.IsZero() {
t.Fatal("NextRunAt should be set on add")
}
out.Reset()
if code := runCronWith(store, now, []string{"list"}, &out, &errb); code != 0 {
t.Fatalf("list exit=%d", code)
}
if !strings.Contains(out.String(), jobs[0].ID) || !strings.Contains(out.String(), "0 9 * * *") {
t.Fatalf("list output missing job:\n%s", out.String())
}
if code := runCronWith(store, now, []string{"pause", jobs[0].ID}, &out, &errb); code != 0 {
t.Fatalf("pause exit=%d", code)
}
j, err := store.Get(jobs[0].ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if j.Status != cron.StatusPaused {
t.Fatalf("pause did not persist, status=%q", j.Status)
}
if code := runCronWith(store, now, []string{"rm", jobs[0].ID}, &out, &errb); code != 0 {
t.Fatalf("rm exit=%d", code)
}
jobs, err = store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(jobs) != 0 {
t.Fatalf("expected removed, got %v", jobs)
}
}
func TestCronAddRejectsBadExpr(t *testing.T) {
store := testCronStore(t)
var out, errb bytes.Buffer
now := func() time.Time { return time.Now() }
if code := runCronWith(store, now, []string{"add", "99 * * * *", "--prompt", "x"}, &out, &errb); code == 0 {
t.Fatal("expected non-zero exit for invalid cron expr")
}
if !strings.Contains(errb.String(), "minute") {
t.Fatalf("error should name the bad field, got %q", errb.String())
}
}
func TestCronAddRecipe(t *testing.T) {
store := testCronStore(t)
var out, errb bytes.Buffer
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
if code := runCronWith(store, now, []string{"add", "--recipe", "git-recap"}, &out, &errb); code != 0 {
t.Fatalf("add --recipe exit=%d err=%s", code, errb.String())
}
jobs, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(jobs) != 1 || jobs[0].Expr != "*/30 * * * *" {
t.Fatalf("recipe job not stored: %+v", jobs)
}
}
func TestCronAddExplicitExprOverridesRecipe(t *testing.T) {
store := testCronStore(t)
var out, errb bytes.Buffer
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
// An explicit positional schedule must win over the recipe's default expr
// instead of being silently dropped.
if code := runCronWith(store, now, []string{"add", "0 9 * * *", "--recipe", "git-recap"}, &out, &errb); code != 0 {
t.Fatalf("add exit=%d err=%s", code, errb.String())
}
jobs, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(jobs) != 1 || jobs[0].Expr != "0 9 * * *" {
t.Fatalf("explicit expr should win over recipe, got: %+v", jobs)
}
// The recipe's prompt must actually propagate — assert the exact recipe text,
// not just non-empty (which a default prompt would also satisfy).
recipe, ok := cron.Recipe("git-recap")
if !ok {
t.Fatal("git-recap recipe missing")
}
if jobs[0].Prompt != recipe.Prompt {
t.Fatalf("recipe prompt not propagated: got %q want %q", jobs[0].Prompt, recipe.Prompt)
}
}
func TestCronAddRejectsExtraArgs(t *testing.T) {
store := testCronStore(t)
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
var out, errb bytes.Buffer
if code := runCronWith(store, now, []string{"add", "0 9 * * *", "extra", "--prompt", "x"}, &out, &errb); code == 0 {
t.Fatal("expected non-zero exit for extra positional args")
}
jobs, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(jobs) != 0 {
t.Fatalf("no job should be stored on a usage error, got %v", jobs)
}
}
func TestCronResumeRejectsImpossibleSchedule(t *testing.T) {
store := testCronStore(t)
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
job, err := store.Add(cron.Job{Expr: "0 0 30 2 *", Prompt: "x", Status: cron.StatusPaused})
if err != nil {
t.Fatalf("Add: %v", err)
}
var out, errb bytes.Buffer
if code := runCronWith(store, now, []string{"resume", job.ID}, &out, &errb); code == 0 {
t.Fatal("resume of an impossible schedule must be rejected")
}
j, err := store.Get(job.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if j.Status != cron.StatusPaused {
t.Fatalf("job must remain paused after a rejected resume, got %q", j.Status)
}
}