package tools_test import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/tools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } func templateRepoJSON(name string, isTemplate bool) string { return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`, name, name, name, name, isTemplate) } // fakeTemplateServer serves the whole create-from-template flow off an in-memory // file map, driving the tool's tree-walk. Records writes/deletes/put-bodies. type fakeTemplateServer struct { mu sync.Mutex files map[string]string // path -> raw (un-substituted) content genBranch string // default_branch returned by /generate ("" to force fallback) generated bool generateCalls int // # times POST .../generate was hit — resume must never increment this puts []string deletes []string putBodies map[string]string // path -> decoded written content repoGetsPost int // GET dest after generate (branch fallback) } func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer { return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}} } // newFakeTemplateServerResumed simulates a repo that already exists from a // prior (real) generate call — GET dest succeeds immediately, without a // /generate call first. Used for resume:true tests. func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer { f := newFakeTemplateServer(files, genBranch) f.generated = true return f } func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() w.Header().Set("Content-Type", "application/json") p := r.URL.Path switch { case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl: _, _ = w.Write([]byte(templateRepoJSON(tmpl, true))) case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest: if !f.generated { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) return } f.repoGetsPost++ _, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest) case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate": f.generated = true f.generateCalls++ w.WriteHeader(http.StatusCreated) _, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, dest, dest, f.genBranch, dest, dest) case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"): var entries []string for path := range f.files { entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-"))) } // include a tree (directory) entry to exercise the blob filter entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`) _, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ",")) case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") body, ok := f.files[path] if !ok { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) return } _, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`, path, strings.ReplaceAll(path, "/", "-"), encb64(body)) // POST = create (new/renamed file, no sha), PUT = update (existing, with sha). case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") raw, _ := io.ReadAll(r.Body) var args struct { Content string `json:"content"` } _ = json.Unmarshal(raw, &args) dec, _ := base64.StdEncoding.DecodeString(args.Content) f.puts = append(f.puts, path) f.putBodies[path] = string(dec) if r.Method == http.MethodPost { w.WriteHeader(http.StatusCreated) } else { w.WriteHeader(http.StatusOK) } _, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`)) case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") f.deletes = append(f.deletes, path) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`)) default: t.Errorf("unexpected request: %s %s", r.Method, p) w.WriteHeader(http.StatusNotFound) } } } func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate { return tools.NewCreateProjectFromTemplate( gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl) } func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut { t.Helper() res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON)) require.NoError(t, err) var out createOut require.NoError(t, json.Unmarshal(res, &out)) return out } type createOut struct { FullName string `json:"full_name"` DefaultBranch string `json:"default_branch"` FilesSubstituted []string `json:"files_substituted"` PartialFailure string `json:"partial_failure,omitempty"` DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"` } // Happy path: whole-tree substitution, content + path rename, correct module host. func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) { files := map[string]string{ "go.mod": "module __MODULE_PATH__\n\ngo 1.26\n", "README.md": "# __PROJECT_NAME__\n", "cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n", "pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched } f := newFakeTemplateServer(files, "main") srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) assert.Equal(t, "main", out.DefaultBranch) assert.Empty(t, out.PartialFailure) // content-substituted files present; untouched file absent assert.Contains(t, out.FilesSubstituted, "go.mod") assert.Contains(t, out.FilesSubstituted, "README.md") assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go") // path rename recorded as "old -> new" assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go") // module host substituted correctly (git.d-ma.be, not gitea.d-ma.be) assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"]) // rename: new path written, old path deleted assert.Contains(t, f.puts, "cmd/new-svc/main.go") assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go") assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n", f.putBodies["cmd/new-svc/main.go"]) // the untouched file was never written assert.NotContains(t, f.puts, "pkg/litellm/x.go") } // The /generate response omits default_branch (the live gitea behavior the old // mock hid) → tool must re-fetch the repo and still substitute. func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) { files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} f := newFakeTemplateServer(files, "") // generate returns default_branch:"" srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback") assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch") assert.Contains(t, out.FilesSubstituted, "go.mod") assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"]) assert.Empty(t, out.PartialFailure) } // Fail loud: a template whose files carry no placeholders yields nothing // substituted — surface it rather than returning silent success. func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) { files := map[string]string{"README.md": "# static, no placeholders\n"} f := newFakeTemplateServer(files, "main") srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) assert.Empty(t, out.FilesSubstituted) assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success") } // Write failure mid-pass → partial_failure populated, no Go error. func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) { files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} f := newFakeTemplateServer(files, "main") base := f.handler(t, "template-go-agent", "new-svc") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"boom"}`)) return } base(w, r) })) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) assert.NotEmpty(t, out.PartialFailure) assert.Contains(t, out.PartialFailure, "go.mod") } // dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true. func TestCreateProject_DispatchAllow(t *testing.T) { tests := []struct { name string argsJSON string wantFile bool }{ {"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true}, {"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false}, {"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} f := newFakeTemplateServer(files, "main") srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON) require.Empty(t, out.PartialFailure) if tc.wantFile { assert.Contains(t, out.FilesSubstituted, ".dispatch-allow") assert.Contains(t, f.puts, ".dispatch-allow") assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3") } else { assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow") assert.NotContains(t, f.puts, ".dispatch-allow") } }) } } // ── resume: durable substitution against infra#179 (gitea-mcp#50) ─────────── // resume:true requires an ALREADY-CREATED destination — there is nothing to // resume otherwise. func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"not found"}`)) })) defer srv.Close() _, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrValidation) assert.Contains(t, err.Error(), "nothing to resume") } // resume:true on an existing repo continues substitution — fixes only what's // still wrong, leaves already-correct files untouched, and never calls // /generate again (the whole point: no re-creation, just continuation). func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) { files := map[string]string{ "go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run "README.md": "# __PROJECT_NAME__\n", // still needs substitution } f := newFakeTemplateServerResumed(files, "main") srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`) assert.Empty(t, out.PartialFailure) assert.Equal(t, 0, f.generateCalls, "resume must never call /generate") assert.Contains(t, out.FilesSubstituted, "README.md") assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported") assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten") assert.Contains(t, f.puts, "README.md") } // resume:true when everything is already substituted is the expected steady // state (a prior resume already finished the job, or this is a redundant // re-invoke) — success, NOT the "no placeholders substituted" loud failure // that a fresh (non-resume) create would trigger. func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) { files := map[string]string{ "go.mod": "module git.d-ma.be/mathias/new-svc\n", "README.md": "# new-svc\n", } f := newFakeTemplateServerResumed(files, "main") srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`) assert.Empty(t, out.FilesSubstituted) assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure") } // A resume where a prior partial run's rename write SUCCEEDED but its paired // delete FAILED (both are separate, non-atomic API calls) must complete // cleanly: recognize the new path is already correct, skip re-writing it, and // just finish the outstanding delete of the stray old path (gitea-mcp#53). func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) { files := map[string]string{ // stray: delete never completed in the prior run "cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n", // already correct: the write half of the same prior rename DID complete "cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n", } f := newFakeTemplateServerResumed(files, "main") srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`) assert.Empty(t, out.PartialFailure) assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go") assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten") assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen") } // dispatch_allow injection is idempotent on resume: if .dispatch-allow already // has the correct content (from an earlier successful injection), re-invoking // must not attempt another write — and must not error the way a naive // create-only write would (gitea 409/422 on an existing path with no sha). func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) { // Phase 1: a normal (non-resume) call captures the REAL content the tool // writes for .dispatch-allow, without the test needing to know the exact // unexported constant. seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"} seedSrv := newFakeTemplateServer(seedFiles, "main") srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc")) out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`) srv1.Close() require.Empty(t, out1.PartialFailure) realContent := seedSrv.putBodies[".dispatch-allow"] require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow") // Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus // one file still needing substitution. files := map[string]string{ ".dispatch-allow": realContent, "README.md": "# __PROJECT_NAME__\n", } f := newFakeTemplateServerResumed(files, "main") srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) defer srv2.Close() out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`) assert.Empty(t, out2.PartialFailure) assert.Empty(t, out2.DispatchAllowFailure) assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten") assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted") assert.Contains(t, out2.FilesSubstituted, "README.md") } // dispatch_allow injection failing is reported in its OWN field, distinct from // PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch // eligibility still fails, and the caller must be able to tell them apart. func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) { files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} f := newFakeTemplateServer(files, "main") base := f.handler(t, "template-go-agent", "new-svc") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message":"boom"}`)) return } base(w, r) })) defer srv.Close() out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`) assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure") assert.NotEmpty(t, out.DispatchAllowFailure) assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow") assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure") } // ── guardrails unchanged by the rewrite ────────────────────────────────────── func TestCreateProject_NameRegexFailure(t *testing.T) { _, err := tools.NewCreateProjectFromTemplate( gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent", ).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrValidation) } func TestCreateProject_AllowlistRejects(t *testing.T) { _, err := tools.NewCreateProjectFromTemplate( gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent", ).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`)) require.Error(t, err) assert.Contains(t, err.Error(), "allowlist") } func TestCreateProject_NotTemplate(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" { _, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false))) return } t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) })) defer srv.Close() _, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrValidation) } func TestCreateProject_DestinationExists(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/v1/repos/mathias/template-go-agent": _, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true))) case "/api/v1/repos/mathias/new-svc": _, _ = w.Write([]byte(templateRepoJSON("new-svc", false))) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() _, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) require.Error(t, err) assert.ErrorIs(t, err, gitea.ErrConflict) }